feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -35,6 +35,7 @@ public static class ApplicationBuilderExtensions
|
||||
return;
|
||||
|
||||
var ownerEmail = configuration["Bootstrap:OwnerEmail"]?.Trim().ToLowerInvariant();
|
||||
var ownerPassword = configuration["Bootstrap:OwnerPassword"];
|
||||
var hasUsers = await db.Users.AnyAsync();
|
||||
|
||||
// ── Double-check SeedAudit after the migration — if another pod wrote it
|
||||
@@ -55,20 +56,20 @@ public static class ApplicationBuilderExtensions
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ownerEmail))
|
||||
throw new InvalidOperationException("Bootstrap:OwnerEmail is required for initial setup.");
|
||||
if (string.IsNullOrWhiteSpace(ownerPassword) || ownerPassword.Length < 10)
|
||||
throw new InvalidOperationException(
|
||||
"Bootstrap:OwnerPassword is required for initial setup and must contain at least 10 characters.");
|
||||
|
||||
var initialDisplayName = PasswordHelper.BuildOwnerDisplayName(ownerEmail);
|
||||
var initialPassword = PasswordHelper.GenerateTemporaryPassword();
|
||||
|
||||
db.Users.Add(new NexusUser
|
||||
{
|
||||
Email = ownerEmail,
|
||||
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
|
||||
DisplayName = initialDisplayName,
|
||||
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
||||
PasswordHash = PasswordSecurity.Hash(ownerPassword),
|
||||
Role = "owner"
|
||||
});
|
||||
|
||||
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
||||
}
|
||||
|
||||
// Record the seed attempt regardless of whether users already existed.
|
||||
@@ -86,6 +87,8 @@ public static class ApplicationBuilderExtensions
|
||||
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
|
||||
{
|
||||
app.UseForwardedHeaders();
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
app.UseRateLimiter();
|
||||
app.UseApiKeyAuthentication();
|
||||
app.UseAuthentication();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Diagnostics;
|
||||
using Nexus.Api.Observability;
|
||||
using Npgsql;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Nexus.Api.Extensions;
|
||||
|
||||
public static class PlatformServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the canonical OpenAPI document and privacy-safe telemetry.
|
||||
/// OTLP export is opt-in; without an endpoint Nexus keeps only in-process
|
||||
/// instrumentation and does not add a production telemetry service.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusPlatform(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddOpenApi("v1");
|
||||
services.AddProblemDetails(options =>
|
||||
{
|
||||
options.CustomizeProblemDetails = context =>
|
||||
{
|
||||
context.ProblemDetails.Extensions["traceId"] =
|
||||
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
|
||||
};
|
||||
});
|
||||
|
||||
var telemetry = services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource.AddService(
|
||||
serviceName: "nexus-api",
|
||||
serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString()))
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddMeter(NexusTelemetry.SourceName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
{
|
||||
// Exception messages and stack traces may contain prompts,
|
||||
// paths or other operator content.
|
||||
options.RecordException = false;
|
||||
options.Filter = context =>
|
||||
!context.Request.Path.StartsWithSegments("/health");
|
||||
})
|
||||
.AddHttpClientInstrumentation(options =>
|
||||
{
|
||||
options.RecordException = false;
|
||||
})
|
||||
.AddNpgsql()
|
||||
.AddSource(NexusTelemetry.SourceName)
|
||||
.AddProcessor(new NexusTelemetryRedactionProcessor()));
|
||||
|
||||
var endpointValue = configuration["OpenTelemetry:OtlpEndpoint"]
|
||||
?? Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
|
||||
if (Uri.TryCreate(endpointValue, UriKind.Absolute, out var endpoint))
|
||||
{
|
||||
telemetry.UseOtlpExporter(OtlpExportProtocol.Grpc, endpoint);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Http.Resilience;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using ModelContextProtocol.AspNetCore;
|
||||
using Nexus.Api.Data;
|
||||
@@ -12,6 +15,7 @@ using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Routing;
|
||||
using Nexus.Api.Services;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.RateLimiting;
|
||||
@@ -53,7 +57,12 @@ public static class ServiceCollectionExtensions
|
||||
};
|
||||
});
|
||||
|
||||
services.AddAuthorization();
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.FallbackPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
});
|
||||
services.AddAntiforgery(options =>
|
||||
{
|
||||
options.HeaderName = "X-CSRF-TOKEN";
|
||||
@@ -77,7 +86,7 @@ public static class ServiceCollectionExtensions
|
||||
options.OnRejected = async (context, ct) =>
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.HttpContext.Response.Headers.ContentType = "application/json";
|
||||
context.HttpContext.Response.Headers.ContentType = "application/problem+json";
|
||||
|
||||
var retryAfterSeconds = 60;
|
||||
|
||||
@@ -93,13 +102,19 @@ public static class ServiceCollectionExtensions
|
||||
context.HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
||||
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||
|
||||
var body = new
|
||||
var body = new ProblemDetails
|
||||
{
|
||||
error = "rate_limit_exceeded",
|
||||
message = $"Too many attempts. Try again in {retryAfterSeconds} second(s).",
|
||||
remaining = 0,
|
||||
retryAfterSeconds
|
||||
Type = "https://httpstatuses.com/429",
|
||||
Title = "Rate limit exceeded",
|
||||
Status = StatusCodes.Status429TooManyRequests,
|
||||
Detail = $"Too many attempts. Try again in {retryAfterSeconds} second(s)."
|
||||
};
|
||||
body.Extensions["code"] = "rate_limit_exceeded";
|
||||
body.Extensions["remaining"] = 0;
|
||||
body.Extensions["retryAfterSeconds"] = retryAfterSeconds;
|
||||
body.Extensions["traceId"] =
|
||||
System.Diagnostics.Activity.Current?.Id
|
||||
?? context.HttpContext.TraceIdentifier;
|
||||
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(body, ct);
|
||||
};
|
||||
@@ -131,13 +146,45 @@ public static class ServiceCollectionExtensions
|
||||
/// <summary>
|
||||
/// Configures forwarded headers for reverse proxy scenarios.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
|
||||
public static IServiceCollection AddNexusForwardedHeaders(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
var forwardLimit = configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1;
|
||||
if (forwardLimit is < 1 or > 5)
|
||||
throw new InvalidOperationException("ForwardedHeaders:ForwardLimit must be between 1 and 5.");
|
||||
options.ForwardLimit = forwardLimit;
|
||||
|
||||
foreach (var configuredProxy in configuration
|
||||
.GetSection("ForwardedHeaders:KnownProxies")
|
||||
.Get<string[]>() ?? [])
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredProxy))
|
||||
continue;
|
||||
|
||||
if (!IPAddress.TryParse(configuredProxy, out var proxy))
|
||||
throw new InvalidOperationException(
|
||||
$"ForwardedHeaders:KnownProxies contains invalid IP address '{configuredProxy}'.");
|
||||
|
||||
options.KnownProxies.Add(proxy);
|
||||
}
|
||||
|
||||
foreach (var configuredNetwork in configuration
|
||||
.GetSection("ForwardedHeaders:KnownNetworks")
|
||||
.Get<string[]>() ?? [])
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredNetwork))
|
||||
continue;
|
||||
|
||||
if (!System.Net.IPNetwork.TryParse(configuredNetwork, out var network))
|
||||
throw new InvalidOperationException(
|
||||
$"ForwardedHeaders:KnownNetworks contains invalid CIDR '{configuredNetwork}'.");
|
||||
|
||||
options.KnownIPNetworks.Add(network);
|
||||
}
|
||||
});
|
||||
|
||||
return services;
|
||||
@@ -174,34 +221,58 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
||||
var runtimeReadClient = services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
||||
{
|
||||
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
});
|
||||
AddOpenClawReadResilience(runtimeReadClient);
|
||||
|
||||
services.AddHttpClient("gateway", client =>
|
||||
var gatewayReadClient = services.AddHttpClient("gateway", client =>
|
||||
{
|
||||
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
});
|
||||
AddOpenClawReadResilience(gatewayReadClient);
|
||||
|
||||
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||
var historyReadClient = services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
});
|
||||
AddOpenClawReadResilience(historyReadClient);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void AddOpenClawReadResilience(IHttpClientBuilder client)
|
||||
{
|
||||
client.AddStandardResilienceHandler(options =>
|
||||
{
|
||||
options.RateLimiter.DefaultRateLimiterOptions.PermitLimit = 4;
|
||||
options.RateLimiter.DefaultRateLimiterOptions.QueueLimit = 0;
|
||||
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
|
||||
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(30);
|
||||
options.Retry.MaxRetryAttempts = 2;
|
||||
options.Retry.Delay = TimeSpan.FromMilliseconds(250);
|
||||
options.Retry.UseJitter = true;
|
||||
options.Retry.DisableForUnsafeHttpMethods();
|
||||
options.CircuitBreaker.FailureRatio = 0.5;
|
||||
options.CircuitBreaker.MinimumThroughput = 4;
|
||||
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
|
||||
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers application domain services (transient, scoped, singleton).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
|
||||
public static IServiceCollection AddNexusApplicationServices(
|
||||
this IServiceCollection services,
|
||||
bool includeHostedServices = true)
|
||||
{
|
||||
services.AddMcpServer()
|
||||
.WithHttpTransport(options => options.Stateless = true)
|
||||
@@ -209,6 +280,8 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddOptions<StaleTaskRecoveryOptions>()
|
||||
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
|
||||
services.AddOptions<AgentProvisioningOptions>()
|
||||
.BindConfiguration(AgentProvisioningOptions.SectionName);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<LoginAttemptTracker>();
|
||||
services.AddTransient<ModelRoutingService>();
|
||||
@@ -219,21 +292,50 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ITaskService, TaskService>();
|
||||
services.AddScoped<IOperationsService, OperationsService>();
|
||||
services.AddScoped<ITeamService, TeamService>();
|
||||
services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
||||
services.AddSingleton<IMemoryService, MemoryService>();
|
||||
services.AddSingleton<IIncidentService, IncidentService>();
|
||||
services.AddSingleton<IDocService, DocService>();
|
||||
services.AddScoped<IMemoryService, MemoryService>();
|
||||
services.AddScoped<IIncidentService, IncidentService>();
|
||||
services.AddScoped<IDocService, DocService>();
|
||||
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
|
||||
services.AddSingleton<DomainEventStreamService>();
|
||||
services.AddSingleton<IDomainEventStreamService>(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<DomainEventStreamService>());
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddScoped<ICalendarService, CalendarService>();
|
||||
services.AddScoped<IOpenClawControlService, OpenClawControlService>();
|
||||
services.AddScoped<IOpenClawAgentConfigurationService, OpenClawAgentConfigurationService>();
|
||||
services.AddScoped<IOpenClawSetupService, OpenClawSetupService>();
|
||||
services.AddSingleton<IOpenClawWizardService, OpenClawWizardService>();
|
||||
services.AddSingleton<IOpenClawManagementState, OpenClawManagementState>();
|
||||
services.AddSingleton<IOpenClawWriteGate, OpenClawWriteGate>();
|
||||
services.AddSingleton<IOpenClawEventProjectionService, OpenClawEventProjectionService>();
|
||||
services.AddSingleton<IOpenClawRunGateway, OpenClawRunGateway>();
|
||||
services.AddScoped<IOpenClawRunService, OpenClawRunService>();
|
||||
services.AddScoped<IOpenClawChatService, OpenClawChatService>();
|
||||
services.AddScoped<IAgentProposalService, AgentProposalService>();
|
||||
services.AddSingleton<AgentProvisioningSignal>();
|
||||
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
|
||||
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
|
||||
|
||||
// ── Gateway WebSocket Connector ──
|
||||
services.AddOptions<GatewayConnectorOptions>()
|
||||
.BindConfiguration(GatewayConnectorOptions.SectionName);
|
||||
services.AddSingleton<IOpenClawDeviceIdentityStore, OpenClawDeviceIdentityStore>();
|
||||
services.AddSingleton<
|
||||
IOpenClawOperationAuditStore,
|
||||
PostgresOpenClawOperationAuditStore>();
|
||||
services.AddSingleton<IGatewayConnector, GatewayConnector>();
|
||||
services.AddHostedService(sp => (GatewayConnector)sp.GetRequiredService<IGatewayConnector>());
|
||||
|
||||
if (includeHostedServices)
|
||||
{
|
||||
services.AddHostedService<OpenClawManagementStateInitializer>();
|
||||
services.AddHostedService(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<DomainEventStreamService>());
|
||||
services.AddHostedService<AgentProvisioningWorker>();
|
||||
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
|
||||
services.AddHostedService(serviceProvider =>
|
||||
(GatewayConnector)serviceProvider.GetRequiredService<IGatewayConnector>());
|
||||
services.AddHostedService<OpenClawEventSubscriptionCoordinator>();
|
||||
services.AddHostedService<OpenClawRunEventReconciler>();
|
||||
}
|
||||
|
||||
// ── Backend Bridge (Agent-Command-Service) ──
|
||||
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
|
||||
@@ -250,6 +352,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||
services.AddScoped<ITaskRepository, TaskRepository>();
|
||||
services.AddScoped<IActivityRepository, ActivityRepository>();
|
||||
services.AddScoped<IOpenClawRunRepository, OpenClawRunRepository>();
|
||||
services.AddScoped<IOpenClawConnectionProfileRepository, OpenClawConnectionProfileRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user