using System.Globalization; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using System.Text.RegularExpressions; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Http; using Nexus.Api.Models; namespace Nexus.Api.Services; /// /// Stable, browser-safe facade over OpenClaw's protocol-v4 control plane. /// Gateway payloads are intentionally normalized here so frontend components /// never depend on OpenClaw's internal storage or raw protocol shapes. /// public sealed class OpenClawControlService( IGatewayConnector connector, IConfiguration configuration, IOpenClawWriteGate writeGate, ILogger logger, IHttpContextAccessor? httpContextAccessor = null, IOpenClawOperationAuditStore? operationAuditStore = null, IOpenClawManagementState? managementState = null) : IOpenClawControlService { private static readonly string[] ApprovalDecisions = ["allow-once", "allow-always", "deny"]; private static readonly HashSet ModelAuthStatuses = [ "ok", "expiring", "expired", "missing", "static" ]; private static readonly HashSet ModelAuthProfileTypes = [ "oauth", "token", "api_key" ]; private static readonly Regex SafeEnvironmentVariablePattern = new( "^[A-Z][A-Z0-9_]{0,127}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SensitiveUsageTextPattern = new( @"(?ix) https?:// | \b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b | \b(?:sk|key|token|secret|password)[-_:=][^\s]+", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly HashSet CronMutationMethods = [ "cron.add", "cron.update", "cron.remove", "cron.run" ]; private static readonly HashSet AllowedCronPatchFields = [ "name", "displayName", "agentId", "sessionKey", "description", "enabled", "deleteAfterRun", "schedule", "trigger", "sessionTarget", "wakeMode", "payload", "delivery", "failureAlert" ]; private static readonly Regex DeliveryTargetPattern = new( @"(?ix) https?://[^\s]+ | \b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b | (? GetCapabilities() { var connected = connector.ConnectionState == GatewayConnectionState.Connected; return CapabilityDefinitions .Select(definition => { var methodAvailable = connected && connector.Supports(definition.Method); var scopeAvailable = connected && HasScope(definition.RequiredScope); var managementAvailable = !CronMutationMethods.Contains(definition.Method) || CronManagementEnabled; var available = methodAvailable && scopeAvailable && managementAvailable; var state = !connected ? "disconnected" : !methodAvailable ? "unsupported" : !scopeAvailable ? "forbidden" : !managementAvailable ? "management_disabled" : "ready"; var reason = state switch { "disconnected" => "Gateway nicht verbunden.", "unsupported" => $"Gateway bietet {definition.Method} nicht an.", "forbidden" => $"Scope {definition.RequiredScope} fehlt.", "management_disabled" => "OpenClaw-Verwaltung ist in Nexus nicht freigegeben.", _ => null }; return new OpenClawCapabilityDto( definition.Id, definition.Label, definition.Method, definition.RequiredScope, available, state, reason); }) .ToArray(); } public async Task GetOverviewAsync( CancellationToken cancellationToken = default) { var tasksTask = GetTasksAsync(100, null, cancellationToken); var sessionsTask = GetSessionsAsync(100, cancellationToken); var cronTask = GetCronJobsAsync(100, cancellationToken); var approvalsTask = GetApprovalsAsync(100, cancellationToken); var activityTask = GetActivityAsync(100, null, cancellationToken); var modelsTask = GetModelsAsync(cancellationToken); var agentsTask = GetAgentsAsync(cancellationToken); await Task.WhenAll( tasksTask, sessionsTask, cronTask, approvalsTask, activityTask, modelsTask, agentsTask); return new OpenClawOverviewDto( GetConnection(), GetCapabilities(), await tasksTask, await sessionsTask, await cronTask, await approvalsTask, await activityTask, await modelsTask, await agentsTask, DateTimeOffset.UtcNow); } public Task> GetTasksAsync( int limit = 100, string? cursor = null, CancellationToken cancellationToken = default) { var parameters = new JsonObject { ["limit"] = Math.Clamp(limit, 1, 500) }; if (!string.IsNullOrWhiteSpace(cursor)) parameters["cursor"] = cursor.Trim(); return InvokeCollectionAsync( "tasks.list", "operator.read", parameters, response => ReadItems(response, "tasks", "items") .Select(MapTask) .Where(item => !string.IsNullOrWhiteSpace(item.Id)) .ToArray(), response => ReadString(response, "nextCursor"), cancellationToken); } public Task> GetSessionsAsync( int limit = 100, CancellationToken cancellationToken = default) { var canAbort = connector.Supports("sessions.abort") && HasScope("operator.write"); return InvokeCollectionAsync( "sessions.list", "operator.read", new { limit = Math.Clamp(limit, 1, 500) }, response => ReadItems(response, "sessions", "items") .Select(item => MapSession(item, canAbort)) .Where(item => !string.IsNullOrWhiteSpace(item.Key)) .ToArray(), _ => null, cancellationToken); } public Task> GetCronJobsAsync( int limit = 100, CancellationToken cancellationToken = default) => GetCronJobsAsync( includeDisabled: true, limit, cursor: null, cancellationToken); public Task> GetCronJobsAsync( bool includeDisabled, int limit = 100, string? cursor = null, CancellationToken cancellationToken = default) { if (!TryDecodeCursor(cursor, out var offset)) { return Task.FromResult(EmptyCollection( "invalid", "Der Cron-Cursor ist ungültig.", "Liste ohne Cursor neu laden.")); } var canRun = CronManagementEnabled && connector.Supports("cron.run") && HasScope("operator.admin"); var boundedLimit = Math.Clamp(limit, 1, 200); return InvokeCollectionAsync( "cron.list", "operator.read", new { includeDisabled, limit = boundedLimit, offset }, response => ReadItems(response, "jobs", "items") .Select(item => MapCronJob(item, canRun)) .Where(item => !string.IsNullOrWhiteSpace(item.Id)) .ToArray(), response => ReadOffsetPaginationCursor( response, offset, boundedLimit, "jobs", "items"), cancellationToken); } public async Task> GetCronJobAsync( string jobId, CancellationToken cancellationToken = default) { var normalizedJobId = jobId.Trim(); var unavailable = OperationUnavailable( "cron.get", "operator.read"); if (unavailable is not null) return unavailable; try { var response = await connector.InvokeAsync( "cron.get", new { id = normalizedJobId }, cancellationToken: cancellationToken); var node = ReadNode(response, "job") ?? response; if (node is null || string.IsNullOrWhiteSpace(ReadString(node, "id", "jobId"))) { return new OpenClawOperationDto( false, "not_found", "OpenClaw-Zeitplan wurde nicht gefunden.", null, "Zeitplanliste aktualisieren und die Job-ID prüfen.", DateTimeOffset.UtcNow); } return new OpenClawOperationDto( true, "ready", "OpenClaw-Zeitplan wurde geladen.", MapCronJobDetail(node), null, DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw cron get failed for {JobId}", normalizedJobId); return OperationFailure(exception); } } public Task> GetCronRunsAsync( string jobId, int limit = 100, string? cursor = null, string? runId = null, CancellationToken cancellationToken = default) { if (!TryDecodeCursor(cursor, out var offset)) { return Task.FromResult(EmptyCollection( "invalid", "Der Cron-History-Cursor ist ungültig.", "Historie ohne Cursor neu laden.")); } var boundedLimit = Math.Clamp(limit, 1, 200); var parameters = new JsonObject { ["scope"] = "job", ["id"] = jobId.Trim(), ["limit"] = boundedLimit, ["offset"] = offset, ["sortDir"] = "desc" }; if (!string.IsNullOrWhiteSpace(runId)) parameters["runId"] = runId.Trim(); return InvokeCollectionAsync( "cron.runs", "operator.read", parameters, response => ReadItems(response, "entries", "runs", "items") .Select(MapCronRun) .Where(item => !string.IsNullOrWhiteSpace(item.JobId)) .ToArray(), response => ReadOffsetPaginationCursor( response, offset, boundedLimit, "entries", "runs", "items"), cancellationToken); } public async Task> GetApprovalsAsync( int limit = 100, CancellationToken cancellationToken = default) { var disconnected = CollectionUnavailable( "approval.history", "operator.approvals"); if (disconnected is not null) return disconnected; var supportedMethods = new[] { "exec.approval.list", "plugin.approval.list", "approval.history" }.Where(connector.Supports).ToArray(); if (supportedMethods.Length == 0) { return EmptyCollection( "unsupported", "Diese OpenClaw-Version bietet keine lesbare Approval-Liste an.", "OpenClaw aktualisieren oder approval.history beziehungsweise *.approval.list aktivieren."); } try { var canResolve = connector.Supports("approval.resolve") && HasScope("operator.approvals"); var calls = supportedMethods.Select(method => connector.InvokeAsync( method, method == "approval.history" ? new { limit = Math.Clamp(limit, 1, 200) } : new { }, cancellationToken: cancellationToken)).ToArray(); var responses = await Task.WhenAll(calls); var unique = new Dictionary(StringComparer.Ordinal); foreach (var response in responses) { foreach (var node in ReadItems(response, "approvals", "items", "history", "pending")) { var approval = MapApproval(node, canResolve); if (!string.IsNullOrWhiteSpace(approval.Id)) unique[approval.Id] = approval; } } var items = unique.Values .OrderBy(item => string.Equals(item.Status, "pending", StringComparison.OrdinalIgnoreCase) ? 0 : 1) .ThenByDescending(item => item.RequestedAt) .Take(Math.Clamp(limit, 1, 200)) .ToArray(); return ReadyCollection(items); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw approval list failed"); return CollectionFailure(exception); } } public async Task> GetActivityAsync( int limit = 100, string? cursor = null, CancellationToken cancellationToken = default) { if (connector.ConnectionState != GatewayConnectionState.Connected) return CollectionFailure( new OpenClawGatewayRpcException( "GATEWAY_DISCONNECTED", "OpenClaw Gateway is not connected.", retryable: true)); if (!connector.Supports("audit.activity.list")) { var eventItems = connector.GetRecentEvents(limit) .Select(MapGatewayEvent) .ToArray(); return new OpenClawCollectionDto( eventItems.Length == 0 ? "unsupported" : "ready", eventItems, null, eventItems.Length == 0 ? "OpenClaw bietet audit.activity.list nicht an." : "Audit-Ledger nicht verfügbar; echte Gateway-Events werden angezeigt.", eventItems.Length == 0 ? "OpenClaw aktualisieren oder Audit-Ledger aktivieren." : null, DateTimeOffset.UtcNow); } var parameters = new JsonObject { ["limit"] = Math.Clamp(limit, 1, 500) }; if (!string.IsNullOrWhiteSpace(cursor)) parameters["cursor"] = cursor.Trim(); return await InvokeCollectionAsync( "audit.activity.list", "operator.read", parameters, response => ReadItems(response, "events", "items") .Select(MapActivity) .ToArray(), response => ReadString(response, "nextCursor"), cancellationToken); } public Task> GetModelsAsync( CancellationToken cancellationToken = default) { return InvokeCollectionAsync( "models.list", "operator.read", new { view = "configured" }, response => ReadItems(response, "models", "items") .Select(MapModel) .Where(item => !string.IsNullOrWhiteSpace(item.Id)) .ToArray(), _ => null, cancellationToken); } public Task> GetModelAuthStatusAsync( bool refresh = false, CancellationToken cancellationToken = default) { return InvokeCollectionAsync( "models.authStatus", "operator.read", new { refresh }, response => ReadItems(response, "providers", "items") .Select(MapModelAuthProvider) .Where(item => !string.IsNullOrWhiteSpace(item.Provider)) .OrderBy(item => item.DisplayName, StringComparer.OrdinalIgnoreCase) .ToArray(), _ => null, cancellationToken); } public Task> GetAgentsAsync( CancellationToken cancellationToken = default) { return InvokeCollectionAsync( "agents.list", "operator.read", new { }, response => ReadItems(response, "agents", "items") .Select(MapAgent) .Where(item => !string.IsNullOrWhiteSpace(item.Id)) .ToArray(), _ => null, cancellationToken); } public async Task> CancelTaskAsync( string taskId, string? reason, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var normalizedTaskId = taskId.Trim(); var taskRef = new EntityRefDto("openclaw-task", normalizedTaskId); var writeUnavailable = await WriteMutationUnavailableAsync( "tasks.cancel", cancellationToken, "operator.write"); if (writeUnavailable is not null) return EnrichUnavailableOrInvalid( writeUnavailable, invocationContext, taskRef); var normalizedReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(); var unavailable = OperationUnavailable("tasks.cancel", "operator.write"); if (unavailable is not null) return EnrichUnavailableOrInvalid(unavailable, invocationContext, taskRef); return await ExecuteMutationAsync( "tasks.cancel", "task", normalizedTaskId, [normalizedReason ?? string.Empty], invocationContext, cancellationToken, async context => { try { var parameters = new JsonObject { ["taskId"] = normalizedTaskId }; if (normalizedReason is not null) parameters["reason"] = normalizedReason; var response = await connector.InvokeAsync( "tasks.cancel", parameters, cancellationToken: cancellationToken, invocationContext: context with { IncludeIdempotencyParameter = false }); var found = ReadBool(response, "found") ?? true; var cancelled = ReadBool(response, "cancelled") ?? false; var taskNode = ReadNode(response, "task"); var task = taskNode is null ? null : MapTask(taskNode); var message = !found ? "OpenClaw-Task wurde nicht gefunden." : cancelled ? "OpenClaw-Task wurde abgebrochen." : ReadString(response, "reason") ?? "OpenClaw konnte den Task nicht abbrechen."; return new OpenClawOperationDto( found && cancelled, found && cancelled ? "completed" : "rejected", message, task, found ? null : "Task-Liste aktualisieren und die Task-ID prüfen.", DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw task cancellation failed for {TaskId}", taskId); return OperationFailure(exception); } }); } public async Task> AbortSessionAsync( string sessionKey, string? runId, bool clearQueued, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var normalizedSessionKey = sessionKey.Trim(); var sessionRef = new EntityRefDto("session", normalizedSessionKey); var writeUnavailable = await WriteMutationUnavailableAsync( "sessions.abort", cancellationToken, "operator.write"); if (writeUnavailable is not null) return EnrichUnavailableOrInvalid( writeUnavailable, invocationContext, sessionRef); var normalizedRunId = string.IsNullOrWhiteSpace(runId) ? null : runId.Trim(); var unavailable = OperationUnavailable("sessions.abort", "operator.write"); if (unavailable is not null) return EnrichUnavailableOrInvalid(unavailable, invocationContext, sessionRef); return await ExecuteMutationAsync( "sessions.abort", "session", normalizedSessionKey, [normalizedRunId ?? string.Empty, clearQueued.ToString(CultureInfo.InvariantCulture)], invocationContext, cancellationToken, async context => { try { var parameters = new JsonObject { ["key"] = normalizedSessionKey, ["clearQueued"] = clearQueued }; if (normalizedRunId is not null) parameters["runId"] = normalizedRunId; var response = await connector.InvokeAsync( "sessions.abort", parameters, cancellationToken: cancellationToken, invocationContext: context with { IncludeIdempotencyParameter = false }); var data = new Dictionary { ["sessionKey"] = normalizedSessionKey, ["runId"] = ReadString(response, "runId") ?? normalizedRunId, ["aborted"] = ReadBool(response, "aborted") ?? ReadBool(response, "ok") ?? true, ["queuedCleared"] = clearQueued }; return new OpenClawOperationDto( true, "completed", clearQueued ? "Aktiver Run und wartende Follow-ups wurden gestoppt." : "Aktiver Run wurde gestoppt.", data, null, DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw session abort failed"); return OperationFailure(exception); } }); } public async Task> PatchSessionModelAsync( string sessionKey, string model, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var normalizedSessionKey = sessionKey.Trim(); var sessionRef = new EntityRefDto("session", normalizedSessionKey); var writeUnavailable = await WriteMutationUnavailableAsync( "sessions.patch", cancellationToken, "operator.write"); if (writeUnavailable is not null) return EnrichUnavailableOrInvalid( writeUnavailable, invocationContext, sessionRef); var normalizedModel = model.Trim(); var unavailable = OperationUnavailable("sessions.patch", "operator.write"); if (unavailable is not null) return EnrichUnavailableOrInvalid(unavailable, invocationContext, sessionRef); return await ExecuteMutationAsync( "sessions.patch", "session", normalizedSessionKey, [normalizedModel], invocationContext, cancellationToken, async context => { try { var response = await connector.InvokeAsync( "sessions.patch", new { key = normalizedSessionKey, model = normalizedModel }, cancellationToken: cancellationToken, invocationContext: context with { IncludeIdempotencyParameter = false }); var resolvedModel = ReadString(response, "model", "resolvedModel") ?? normalizedModel; var provider = ReadString(response, "provider") ?? ExtractProvider(resolvedModel); var data = new Dictionary { ["sessionKey"] = normalizedSessionKey, ["model"] = resolvedModel, ["provider"] = provider }; return new OpenClawOperationDto( true, "completed", "Session-Modell wurde in OpenClaw aktualisiert.", data, null, DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw session model patch failed"); return OperationFailure(exception); } }); } public async Task> CreateCronJobAsync( CreateOpenClawCronJobRequest request, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var requestedCronRef = new EntityRefDto( "cron", string.IsNullOrWhiteSpace(request.DeclarationKey) ? "new" : request.DeclarationKey.Trim(), string.IsNullOrWhiteSpace(request.DisplayName) ? request.Name?.Trim() : request.DisplayName.Trim()); var unavailable = await WriteMutationUnavailableAsync( "cron.add", cancellationToken); if (unavailable is not null) return EnrichUnavailableOrInvalid(unavailable, invocationContext, requestedCronRef); var validation = ValidateCreateCronRequest(request); if (validation is not null) return EnrichUnavailableOrInvalid(validation, invocationContext, requestedCronRef); var parameters = BuildCronCreateParameters(request); var targetId = !string.IsNullOrWhiteSpace(request.DeclarationKey) ? request.DeclarationKey.Trim() : $"create:{OpenClawInvocationContextFactory.Hash(CanonicalJson(parameters))[..16]}"; var targetRef = new EntityRefDto("cron", targetId, requestedCronRef.Label); if (!AllowCommandCron && IsRestrictedCronDefinition(parameters)) { return EnrichUnavailableOrInvalid( RestrictedCronOperation(), invocationContext, targetRef); } return await ExecuteMutationAsync( "cron.add", "cron-job", targetId, [CanonicalJson(parameters)], invocationContext, cancellationToken, async context => { try { var response = await connector.InvokeAsync( "cron.add", parameters, cancellationToken: cancellationToken, invocationContext: context with { IncludeIdempotencyParameter = false }); var jobNode = ReadNode(response, "job") ?? response; if (jobNode is null || string.IsNullOrWhiteSpace(ReadString(jobNode, "id", "jobId"))) { return new OpenClawOperationDto( false, "rejected", "OpenClaw hat keinen Zeitplan bestätigt.", null, "Zeitplanliste aktualisieren und OpenClaw-Logs prüfen.", DateTimeOffset.UtcNow); } var created = ReadBool(response, "created"); return new OpenClawOperationDto( true, "completed", created == false ? "Deklarativer OpenClaw-Zeitplan wurde aktualisiert." : "OpenClaw-Zeitplan wurde erstellt.", MapCronJobDetail(jobNode), null, DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw cron create failed"); return OperationFailure(exception); } }); } public async Task> PatchCronJobAsync( string jobId, JsonObject patch, string? expectedHash, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var normalizedJobId = jobId.Trim(); var unavailable = await WriteMutationUnavailableAsync( "cron.update", cancellationToken); if (unavailable is not null) return EnrichUnavailableOrInvalid( unavailable, invocationContext, new EntityRefDto("cron", normalizedJobId)); var validation = ValidateCronPatch(patch); if (validation is not null) return EnrichUnavailableOrInvalid( validation, invocationContext, new EntityRefDto("cron", normalizedJobId)); return await ExecuteMutationAsync( "cron.update", "cron-job", normalizedJobId, [CanonicalJson(patch), NormalizeExpectedHash(expectedHash) ?? string.Empty], invocationContext, cancellationToken, async context => { try { var current = await ReadCronJobNodeAsync(normalizedJobId, cancellationToken); if (current is null) return CronNotFound(); if (!MatchesExpectedHash(current, expectedHash)) return CronConflict(); var onlyDisablesRestrictedJob = patch.Count == 1 && ReadBool(patch, "enabled") == false; var effectiveDefinition = current.DeepClone(); if (effectiveDefinition is JsonObject effectiveObject) { if (ReadNode(patch, "schedule") is { } patchedSchedule) effectiveObject["schedule"] = patchedSchedule.DeepClone(); if (ReadNode(patch, "payload") is { } patchedPayload) effectiveObject["payload"] = patchedPayload.DeepClone(); } if (!AllowCommandCron && IsRestrictedCronDefinition(effectiveDefinition) && !onlyDisablesRestrictedJob) { return RestrictedCronOperation(); } var parameters = new JsonObject { ["id"] = normalizedJobId, ["patch"] = patch.DeepClone() }; var response = await connector.InvokeAsync( "cron.update", parameters, cancellationToken: cancellationToken, invocationContext: context with { IncludeIdempotencyParameter = false }); var jobNode = ReadNode(response, "job") ?? response; if (jobNode is null || string.IsNullOrWhiteSpace(ReadString(jobNode, "id", "jobId"))) { return new OpenClawOperationDto( false, "rejected", "OpenClaw hat die Zeitplanänderung nicht bestätigt.", null, "Zeitplandetail aktualisieren und OpenClaw-Logs prüfen.", DateTimeOffset.UtcNow); } return new OpenClawOperationDto( true, "completed", "OpenClaw-Zeitplan wurde aktualisiert.", MapCronJobDetail(jobNode), null, DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning( exception, "OpenClaw cron update failed for {JobId}", normalizedJobId); return OperationFailure(exception); } }); } public async Task> DeleteCronJobAsync( string jobId, string? expectedHash = null, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var normalizedJobId = jobId.Trim(); var unavailable = await WriteMutationUnavailableAsync( "cron.remove", cancellationToken); if (unavailable is not null) return EnrichUnavailableOrInvalid( unavailable, invocationContext, new EntityRefDto("cron", normalizedJobId)); return await ExecuteMutationAsync( "cron.remove", "cron-job", normalizedJobId, [NormalizeExpectedHash(expectedHash) ?? string.Empty], invocationContext, cancellationToken, async context => { try { if (!string.IsNullOrWhiteSpace(expectedHash)) { var current = await ReadCronJobNodeAsync(normalizedJobId, cancellationToken); if (current is null) return CronNotFound(); if (!MatchesExpectedHash(current, expectedHash)) return CronConflict(); } var response = await connector.InvokeAsync( "cron.remove", new { id = normalizedJobId }, cancellationToken: cancellationToken, invocationContext: context with { IncludeIdempotencyParameter = false }); var removed = ReadBool(response, "removed") ?? ReadBool(response, "ok") ?? false; return new OpenClawOperationDto( removed, removed ? "completed" : "not_found", removed ? "OpenClaw-Zeitplan wurde gelöscht." : "OpenClaw-Zeitplan wurde nicht gefunden.", new Dictionary { ["jobId"] = normalizedJobId, ["removed"] = removed }, removed ? null : "Zeitplanliste aktualisieren.", DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning( exception, "OpenClaw cron delete failed for {JobId}", normalizedJobId); return OperationFailure(exception); } }); } public Task> RunCronJobAsync( string jobId, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) => RunCronJobAsync( jobId, expectedHash: null, cancellationToken, invocationContext); public async Task> RunCronJobAsync( string jobId, string? expectedHash, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var normalizedJobId = jobId.Trim(); var unavailable = await WriteMutationUnavailableAsync( "cron.run", cancellationToken); if (unavailable is not null) return EnrichUnavailableOrInvalid( unavailable, invocationContext, new EntityRefDto("cron", normalizedJobId)); return await ExecuteMutationAsync( "cron.run", "cron-job", normalizedJobId, ["force", NormalizeExpectedHash(expectedHash) ?? string.Empty], invocationContext, cancellationToken, async context => { try { if (!AllowCommandCron || !string.IsNullOrWhiteSpace(expectedHash)) { var current = await ReadCronJobNodeAsync(normalizedJobId, cancellationToken); if (current is null) return CronNotFound(); if (!MatchesExpectedHash(current, expectedHash)) return CronConflict(); if (!AllowCommandCron && IsRestrictedCronDefinition(current)) return RestrictedCronOperation(); } var response = await connector.InvokeAsync( "cron.run", new { id = normalizedJobId, mode = "force" }, cancellationToken: cancellationToken, invocationContext: context with { IncludeIdempotencyParameter = false }); var runId = ReadString(response, "runId"); var enqueued = ReadBool(response, "enqueued") ?? (!string.IsNullOrWhiteSpace(runId) ? true : ReadBool(response, "ok") ?? false); var data = new Dictionary { ["jobId"] = normalizedJobId, ["enqueued"] = enqueued, ["runId"] = runId }; return new OpenClawOperationDto( enqueued, enqueued ? "queued" : "rejected", enqueued ? "Cron-Run wurde in OpenClaw eingereiht." : "OpenClaw hat keinen Cron-Run eingereiht.", data, enqueued ? null : "Zeitplanstatus aktualisieren und OpenClaw-Logs prüfen.", DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw cron run failed for {JobId}", jobId); return OperationFailure(exception); } }); } public async Task> ResolveApprovalAsync( string approvalId, string kind, string decision, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var normalizedApprovalId = approvalId.Trim(); var approvalRef = new EntityRefDto("approval", normalizedApprovalId); var unavailable = await WriteMutationUnavailableAsync( "approval.resolve", cancellationToken, "operator.approvals"); if (unavailable is not null) return EnrichUnavailableOrInvalid( unavailable, invocationContext, approvalRef); var normalizedDecision = decision.Trim().ToLowerInvariant(); if (!ApprovalDecisions.Contains(normalizedDecision, StringComparer.Ordinal)) { return EnrichUnavailableOrInvalid( new OpenClawOperationDto( false, "invalid", "Ungültige Approval-Entscheidung.", null, "allow-once, allow-always oder deny verwenden.", DateTimeOffset.UtcNow), invocationContext, approvalRef); } var normalizedKind = kind.Trim().ToLowerInvariant(); var capabilityUnavailable = OperationUnavailable( "approval.resolve", "operator.approvals"); if (capabilityUnavailable is not null) return EnrichUnavailableOrInvalid( capabilityUnavailable, invocationContext, approvalRef); return await ExecuteMutationAsync( "approval.resolve", "approval", normalizedApprovalId, [normalizedKind, normalizedDecision], invocationContext, cancellationToken, async context => { try { var response = await connector.InvokeAsync( "approval.resolve", new { id = normalizedApprovalId, kind = normalizedKind, decision = normalizedDecision }, cancellationToken: cancellationToken, invocationContext: context with { IncludeIdempotencyParameter = false }); var approvalNode = ReadNode(response, "approval", "result") ?? response; var approval = approvalNode is null ? null : MapApproval(approvalNode, canResolve: false); return new OpenClawOperationDto( true, "completed", normalizedDecision == "deny" ? "OpenClaw-Aktion wurde abgelehnt." : "OpenClaw-Aktion wurde freigegeben.", approval, null, DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw approval resolution failed for {ApprovalId}", approvalId); return OperationFailure(exception); } }); } private async Task> ExecuteMutationAsync( string method, string targetType, string targetId, IReadOnlyCollection intentValues, OpenClawInvocationContext? requestedContext, CancellationToken cancellationToken, Func>> operation) { OpenClawInvocationContext context; try { context = ResolveInvocationContext(requestedContext); } catch (ArgumentException exception) { return new OpenClawOperationDto( false, "invalid", "Ungültige Korrelations- oder Idempotenz-Metadaten.", default, exception.Message, DateTimeOffset.UtcNow); } var intentFingerprint = OpenClawInvocationContextFactory.Hash(string.Join( '\u001f', new[] { method, targetType, targetId, context.Actor }.Concat(intentValues))); var descriptor = new OpenClawOperationDescriptor( method, targetType, targetId, intentFingerprint); if (operationAuditStore is not null) { OpenClawOperationClaim claim; try { claim = await operationAuditStore.ClaimAsync( context, descriptor, cancellationToken); } catch (Exception exception) { logger.LogError( exception, "OpenClaw mutation {Method} was blocked because its local audit claim failed", method); return EnrichOperation( new OpenClawOperationDto( false, "audit_unavailable", "OpenClaw-Aktion wurde vor der Ausführung blockiert.", default, "Lokales Operation-Audit reparieren; die Aktion wurde nicht an OpenClaw gesendet.", DateTimeOffset.UtcNow), context, BuildTargetRef(targetType, targetId, default(T)), BuildAffectedRefs(default(T))); } if (claim.Disposition != OpenClawOperationClaimDisposition.Started) { return BuildClaimResult( claim, context, BuildTargetRef(targetType, targetId, default(T))); } } var result = await operation(context); if (operationAuditStore is not null) { try { await operationAuditStore.CompleteAsync( context, descriptor, result.Ok, result.State, result.Ok ? "OpenClaw-Aktion wurde abgeschlossen." : "OpenClaw-Aktion wurde nicht bestätigt.", result.Ok ? null : result.State, cancellationToken); } catch (Exception exception) { logger.LogError( exception, "OpenClaw mutation {Method} completed but its local audit completion failed", method); result = result with { State = result.Ok ? "completed_audit_pending" : "audit_pending", Recovery = "Gateway-Ergebnis liegt vor, aber das lokale Operation-Audit muss geprüft werden." }; } } return EnrichOperation( result, context, BuildTargetRef(targetType, targetId, result.Data), BuildAffectedRefs(result.Data)); } private OpenClawInvocationContext ResolveInvocationContext( OpenClawInvocationContext? requestedContext) { if (requestedContext is not null) { return OpenClawInvocationContextFactory.Create( requestedContext.Actor, requestedContext.IdempotencyKey, requestedContext.CorrelationId, requestedContext.TraceParent, requestedContext.IncludeIdempotencyParameter); } var httpContext = httpContextAccessor?.HttpContext; var actor = ResolveActor(httpContext?.User); var idempotencyKey = ReadHeader(httpContext, "Idempotency-Key"); var correlationId = ReadHeader(httpContext, "X-Correlation-ID") ?? httpContext?.TraceIdentifier; var traceParent = ReadHeader(httpContext, "traceparent"); return OpenClawInvocationContextFactory.Create( actor, idempotencyKey, correlationId, traceParent); } private static OpenClawOperationDto BuildClaimResult( OpenClawOperationClaim claim, OpenClawInvocationContext context, EntityRefDto? primaryRef) { var result = claim.Disposition switch { OpenClawOperationClaimDisposition.Replayed => new OpenClawOperationDto( claim.PreviousOk == true, claim.PreviousOk == true ? "replayed" : claim.PreviousState ?? "replayed", claim.PreviousMessage ?? "Das bereits protokollierte Ergebnis wurde wiederverwendet.", default, claim.PreviousOk == true ? null : "Für einen neuen Versuch einen neuen Idempotency-Key verwenden.", DateTimeOffset.UtcNow), OpenClawOperationClaimDisposition.InDoubt => new OpenClawOperationDto( false, "in_doubt", claim.PreviousMessage ?? "Der frühere Ausführungsstatus ist unklar.", default, "OpenClaw- und Nexus-Audit prüfen; nicht automatisch erneut senden.", DateTimeOffset.UtcNow), _ => new OpenClawOperationDto( false, "idempotency_conflict", claim.PreviousMessage ?? "Der Idempotency-Key kollidiert mit einer anderen Aktion.", default, "Für eine andere Aktion einen neuen Idempotency-Key verwenden.", DateTimeOffset.UtcNow) }; return EnrichOperation(result, context, primaryRef); } private static OpenClawOperationDto EnrichOperation( OpenClawOperationDto operation, OpenClawInvocationContext context, EntityRefDto? primaryRef = null, IEnumerable? affectedRefs = null) => operation with { OperationId = context.CorrelationId, CorrelationId = context.CorrelationId, IdempotencyKey = context.IdempotencyKey, TraceParent = context.TraceParent, Actor = context.Actor, Operation = OperationResultFactory.FromInvocation( context, operation.State, primaryRef, affectedRefs: affectedRefs) }; private OpenClawOperationDto EnrichUnavailableOrInvalid( OpenClawOperationDto operation, OpenClawInvocationContext? requestedContext, EntityRefDto? primaryRef = null) { try { return EnrichOperation( operation, ResolveInvocationContext(requestedContext), primaryRef); } catch (ArgumentException exception) { return InvalidInvocationMetadata(exception); } } private static EntityRefDto BuildTargetRef( string targetType, string targetId, T? data) { if (data is OpenClawCronJobDetailDto cron) { return new EntityRefDto( "cron", cron.Id, cron.DisplayName ?? cron.Name); } if (data is OpenClawTaskDto task) return new EntityRefDto("openclaw-task", task.Id, task.Title); if (data is OpenClawApprovalDto approval) return new EntityRefDto("approval", approval.Id, approval.Title); var entityType = targetType switch { "cron-job" => "cron", "task" => "openclaw-task", "session" => "session", _ => targetType }; return new EntityRefDto(entityType, targetId); } private static IReadOnlyList BuildAffectedRefs(T? data) { var affected = new List(); switch (data) { case OpenClawCronJobDetailDto cron: if (!string.IsNullOrWhiteSpace(cron.AgentId)) affected.Add(new EntityRefDto("agent", cron.AgentId)); if (!string.IsNullOrWhiteSpace(cron.SessionKey)) affected.Add(new EntityRefDto("session", cron.SessionKey)); break; case OpenClawTaskDto task: if (!string.IsNullOrWhiteSpace(task.AgentId)) affected.Add(new EntityRefDto("agent", task.AgentId)); if (!string.IsNullOrWhiteSpace(task.SessionKey)) affected.Add(new EntityRefDto("session", task.SessionKey)); if (Guid.TryParse(task.RunId, out var taskRunId)) affected.Add(new EntityRefDto("run", taskRunId.ToString())); break; case OpenClawApprovalDto approval: if (!string.IsNullOrWhiteSpace(approval.AgentId)) affected.Add(new EntityRefDto("agent", approval.AgentId)); if (!string.IsNullOrWhiteSpace(approval.SessionKey)) affected.Add(new EntityRefDto("session", approval.SessionKey)); break; } return affected; } private static OpenClawOperationDto InvalidInvocationMetadata( ArgumentException exception) => new( false, "invalid", "Ungültige Korrelations- oder Idempotenz-Metadaten.", default, exception.Message, DateTimeOffset.UtcNow); private static string ResolveActor(ClaimsPrincipal? principal) { if (principal?.Identity?.IsAuthenticated != true) return "nexus-system"; return principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? principal.Identity.Name ?? "nexus-authenticated-user"; } private static string? ReadHeader(HttpContext? context, string name) { if (context is null || !context.Request.Headers.TryGetValue(name, out var values)) return null; var value = values.FirstOrDefault()?.Trim(); return string.IsNullOrWhiteSpace(value) ? null : value; } private async Task> InvokeCollectionAsync( string method, string requiredScope, object parameters, Func> mapItems, Func readCursor, CancellationToken cancellationToken) { var unavailable = CollectionUnavailable(method, requiredScope); if (unavailable is not null) return unavailable; try { var response = await connector.InvokeAsync( method, parameters, cancellationToken: cancellationToken); return new OpenClawCollectionDto( "ready", mapItems(response), readCursor(response), null, null, DateTimeOffset.UtcNow); } catch (Exception exception) { logger.LogWarning(exception, "OpenClaw method {Method} failed", method); return CollectionFailure(exception); } } private OpenClawCollectionDto? CollectionUnavailable( string method, string requiredScope) { if (connector.ConnectionState != GatewayConnectionState.Connected) { return EmptyCollection( "disconnected", "OpenClaw Gateway ist nicht verbunden.", GetConnection().Recovery); } if (!connector.Supports(method)) { return EmptyCollection( "unsupported", $"Die verbundene OpenClaw-Version bietet {method} nicht an.", "OpenClaw-Version und Gateway-Featureliste prüfen."); } if (!HasScope(requiredScope)) { return EmptyCollection( "forbidden", $"Nexus fehlt der Scope {requiredScope}.", "Nexus mit dem benötigten Operator-Scope verbinden."); } return null; } private OpenClawOperationDto? OperationUnavailable( string method, string requiredScope) { var collection = CollectionUnavailable(method, requiredScope); return collection is null ? null : new OpenClawOperationDto( false, collection.State, collection.Message ?? "OpenClaw-Aktion ist nicht verfügbar.", default, collection.Recovery, DateTimeOffset.UtcNow); } private async Task?> WriteMutationUnavailableAsync( string method, CancellationToken cancellationToken, string requiredScope = "operator.admin") { var decision = await writeGate.EvaluateAsync( method, requiredScope, cancellationToken); return decision.Allowed ? null : new OpenClawOperationDto( false, decision.State, decision.Message, default, decision.Recovery, DateTimeOffset.UtcNow); } private bool CronManagementEnabled => managementState?.Enabled ?? configuration.GetValue("Integrations:OpenClaw:ManagementEnabled") ?? configuration.GetValue("OpenClaw:ManagementEnabled") ?? false; private bool AllowCommandCron => configuration.GetValue("Integrations:OpenClaw:AllowCommandCron") ?? configuration.GetValue("OpenClaw:AllowCommandCron") ?? false; private static OpenClawOperationDto? ValidateCreateCronRequest( CreateOpenClawCronJobRequest request) { if (string.IsNullOrWhiteSpace(request.Name)) return InvalidCron("Ein Zeitplanname ist erforderlich."); if (request.Schedule is null || string.IsNullOrWhiteSpace(ReadString(request.Schedule, "kind"))) { return InvalidCron( "Ein unterstützter schedule.kind ist erforderlich."); } if (request.Payload is null || string.IsNullOrWhiteSpace(ReadString(request.Payload, "kind"))) { return InvalidCron( "Ein unterstützter payload.kind ist erforderlich."); } var sessionTarget = request.SessionTarget?.Trim(); if (sessionTarget is not ("main" or "isolated" or "current") && !(sessionTarget?.StartsWith("session:", StringComparison.Ordinal) ?? false)) { return InvalidCron( "sessionTarget muss main, isolated, current oder session: sein."); } if (request.WakeMode?.Trim() is not ("now" or "next-heartbeat")) { return InvalidCron( "wakeMode muss now oder next-heartbeat sein."); } return null; } private static OpenClawOperationDto? ValidateCronPatch( JsonObject patch) { if (patch is null || patch.Count == 0) return InvalidCron("Der Zeitplan-Patch ist leer."); var unsupported = patch .Select(property => property.Key) .Where(key => !AllowedCronPatchFields.Contains(key)) .Order(StringComparer.Ordinal) .ToArray(); return unsupported.Length == 0 ? null : InvalidCron( $"Nicht unterstützte Patch-Felder: {string.Join(", ", unsupported)}."); } private static OpenClawOperationDto InvalidCron(string message) => new( false, "invalid", message, default, "Zeitplandaten korrigieren und erneut versuchen.", DateTimeOffset.UtcNow); private static OpenClawOperationDto RestrictedCronOperation() => new( false, "restricted", "Command- und on-exit-Cronjobs sind in Nexus deaktiviert.", default, "Integrations:OpenClaw:AllowCommandCron nur nach Sicherheitsprüfung bewusst aktivieren.", DateTimeOffset.UtcNow); private static OpenClawOperationDto CronNotFound() => new( false, "not_found", "OpenClaw-Zeitplan wurde nicht gefunden.", default, "Zeitplanliste aktualisieren und die Job-ID prüfen.", DateTimeOffset.UtcNow); private static OpenClawOperationDto CronConflict() => new( false, "conflict", "Der OpenClaw-Zeitplan wurde zwischenzeitlich verändert.", default, "Aktuelle Details laden, Änderungen vergleichen und erneut bestätigen.", DateTimeOffset.UtcNow); private static JsonObject BuildCronCreateParameters(CreateOpenClawCronJobRequest request) { var parameters = new JsonObject { ["name"] = request.Name.Trim(), ["enabled"] = request.Enabled, ["schedule"] = request.Schedule.DeepClone(), ["sessionTarget"] = request.SessionTarget.Trim(), ["wakeMode"] = request.WakeMode.Trim(), ["payload"] = request.Payload.DeepClone() }; SetOptionalString(parameters, "description", request.Description); SetOptionalString(parameters, "agentId", request.AgentId); SetOptionalString(parameters, "sessionKey", request.SessionKey); SetOptionalString(parameters, "declarationKey", request.DeclarationKey); SetOptionalString(parameters, "displayName", request.DisplayName); if (request.DeleteAfterRun is not null) parameters["deleteAfterRun"] = request.DeleteAfterRun.Value; if (request.Delivery is not null) parameters["delivery"] = request.Delivery.DeepClone(); if (request.Trigger is not null) parameters["trigger"] = request.Trigger.DeepClone(); if (request.FailureAlert is not null) parameters["failureAlert"] = request.FailureAlert.DeepClone(); return parameters; } private static void SetOptionalString(JsonObject target, string name, string? value) { if (!string.IsNullOrWhiteSpace(value)) target[name] = value.Trim(); } private async Task ReadCronJobNodeAsync( string jobId, CancellationToken cancellationToken) { if (connector.Supports("cron.get")) { var response = await connector.InvokeAsync( "cron.get", new { id = jobId }, cancellationToken: cancellationToken); return (ReadNode(response, "job") ?? response)?.DeepClone(); } if (!connector.Supports("cron.list")) return null; var list = await connector.InvokeAsync( "cron.list", new { includeDisabled = true, limit = 200, offset = 0 }, cancellationToken: cancellationToken); return ReadItems(list, "jobs", "items") .FirstOrDefault(node => string.Equals(ReadString(node, "id", "jobId"), jobId, StringComparison.Ordinal)) ?.DeepClone(); } private static bool MatchesExpectedHash(JsonNode current, string? expectedHash) { var normalized = NormalizeExpectedHash(expectedHash); return normalized is null || string.Equals( normalized, ComputeCronResourceHash(current), StringComparison.OrdinalIgnoreCase); } private static string? NormalizeExpectedHash(string? expectedHash) { var normalized = expectedHash?.Trim(); if (string.IsNullOrWhiteSpace(normalized)) return null; if (normalized.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) normalized = normalized[2..].Trim(); return normalized.Trim('"'); } private static bool IsRestrictedCronDefinition(JsonNode? node) { var schedule = ReadNode(node, "schedule") ?? node; var payload = ReadNode(node, "payload") ?? node; return string.Equals( ReadString(schedule, "kind"), "on-exit", StringComparison.OrdinalIgnoreCase) || string.Equals( ReadString(payload, "kind"), "command", StringComparison.OrdinalIgnoreCase); } private static bool TryDecodeCursor(string? cursor, out int offset) { offset = 0; if (string.IsNullOrWhiteSpace(cursor)) return true; try { var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursor.Trim())); const string prefix = "cron-offset:"; return decoded.StartsWith(prefix, StringComparison.Ordinal) && int.TryParse( decoded[prefix.Length..], NumberStyles.None, CultureInfo.InvariantCulture, out offset) && offset >= 0; } catch (FormatException) { return false; } } private static string EncodeCursor(int offset) => Convert.ToBase64String(Encoding.UTF8.GetBytes( $"cron-offset:{offset.ToString(CultureInfo.InvariantCulture)}")); private static string? ReadOffsetPaginationCursor( JsonNode? response, int currentOffset, int requestedLimit, params string[] collectionKeys) { var nextOffset = ReadInt(response, "nextOffset"); if (nextOffset is >= 0) return EncodeCursor(nextOffset.Value); var returnedCount = ReadItems(response, collectionKeys).Count; if (returnedCount == 0 || ReadBool(response, "hasMore") == false) return null; var gatewayIndicatesMore = ReadBool(response, "hasMore") == true || !string.IsNullOrWhiteSpace(ReadString(response, "nextCursor")) || returnedCount >= requestedLimit; return gatewayIndicatesMore ? EncodeCursor(checked(currentOffset + returnedCount)) : null; } private static string ComputeCronResourceHash(JsonNode node) { var stable = node.DeepClone(); if (stable is JsonObject obj) { foreach (var property in new[] { "state", "nextRunAtMs", "lastRunAtMs", "lastRunStatus", "lastRunError", "lastDelivered", "lastDeliveryStatus", "lastDeliveryError", "lastFailureNotificationDelivered", "lastFailureNotificationDeliveryStatus", "lastFailureNotificationDeliveryError" }) { obj.Remove(property); } } return OpenClawInvocationContextFactory.Hash(CanonicalJson(stable)); } private static string CanonicalJson(JsonNode? node) => Canonicalize(node)?.ToJsonString() ?? "null"; private static JsonNode? Canonicalize(JsonNode? node) { return node switch { null => null, JsonObject obj => new JsonObject(obj .OrderBy(property => property.Key, StringComparer.Ordinal) .Select(property => KeyValuePair.Create( property.Key, Canonicalize(property.Value)))), JsonArray array => new JsonArray(array.Select(Canonicalize).ToArray()), _ => node.DeepClone() }; } private bool HasScope(string scope) { var scopes = connector.GrantedScopes; if (scopes.Contains("operator.admin")) return true; if (scopes.Contains(scope)) return true; if (scope == "operator.read" && scopes.Contains("operator.write")) return true; return false; } private static OpenClawCollectionDto ReadyCollection(IReadOnlyList items) => new("ready", items, null, null, null, DateTimeOffset.UtcNow); private static OpenClawCollectionDto EmptyCollection( string state, string? message, string? recovery) => new(state, Array.Empty(), null, message, recovery, DateTimeOffset.UtcNow); private static OpenClawCollectionDto CollectionFailure(Exception exception) { var failure = DescribeFailure(exception); return EmptyCollection(failure.State, failure.Message, failure.Recovery); } private static OpenClawOperationDto OperationFailure(Exception exception) { var failure = DescribeFailure(exception); return new OpenClawOperationDto( false, failure.State, failure.Message, default, failure.Recovery, DateTimeOffset.UtcNow); } private static OpenClawFailure DescribeFailure(Exception exception) { if (exception is not OpenClawGatewayRpcException gatewayException) { return new OpenClawFailure( "error", "OpenClaw-Anfrage ist fehlgeschlagen.", "Nexus- und OpenClaw-Logs prüfen."); } var detailsCode = ReadString(gatewayException.Details, "code"); var recommended = ReadString(gatewayException.Details, "recommendedNextStep"); var code = gatewayException.Code.ToUpperInvariant(); if (code is "GATEWAY_DISCONNECTED" or "GATEWAY_TIMEOUT" or "UNAVAILABLE") { return new OpenClawFailure( "disconnected", code == "GATEWAY_TIMEOUT" ? "OpenClaw hat nicht rechtzeitig geantwortet." : "OpenClaw Gateway ist nicht verbunden.", "Gateway-Erreichbarkeit, BaseUrl und Dienststatus prüfen."); } if (code is "METHOD_UNAVAILABLE" or "METHOD_NOT_FOUND" or "NOT_IMPLEMENTED") { return new OpenClawFailure( "unsupported", "Die verbundene OpenClaw-Version unterstützt diese Aktion nicht.", "OpenClaw-Version und Feature-Aushandlung prüfen."); } if (code is "FORBIDDEN" or "AUTH_SCOPE_MISMATCH" || string.Equals(detailsCode, "MISSING_SCOPE", StringComparison.OrdinalIgnoreCase)) { return new OpenClawFailure( "forbidden", "OpenClaw hat Nexus nicht den benötigten Operator-Scope gewährt.", "Gateway-Pairing beziehungsweise Nexus-Scopes prüfen."); } if (code.StartsWith("AUTH_", StringComparison.Ordinal) || code.StartsWith("DEVICE_AUTH_", StringComparison.Ordinal)) { return new OpenClawFailure( "forbidden", "OpenClaw-Authentifizierung für Nexus ist fehlgeschlagen.", MapRecommendedNextStep(recommended)); } return new OpenClawFailure( gatewayException.Retryable ? "degraded" : "error", "OpenClaw hat die Anfrage abgelehnt.", MapRecommendedNextStep(recommended)); } private static string MapRecommendedNextStep(string? value) { return value switch { "retry_with_device_token" => "Nexus als Operator-Gerät neu koppeln und mit dem Device-Token erneut verbinden.", "update_auth_configuration" => "OpenClaw-Authentifizierungsmodus und Nexus-Konfiguration abgleichen.", "update_auth_credentials" => "OpenClaw-Token oder Passwort in der Laufzeitkonfiguration aktualisieren.", "wait_then_retry" => "Pairing abschließen und die Verbindung anschließend erneut versuchen.", "review_auth_configuration" => "Gateway-Auth, Loopback-Verbindung und Operator-Scopes prüfen.", _ => "Nexus- und OpenClaw-Logs sowie die Gateway-Konfiguration prüfen." }; } private static string? BuildConnectionRecovery( bool configured, bool credentialConfigured, bool connected, string endpoint, bool versionMatches) { if (!configured) return "Integrations:OpenClaw:BaseUrl als absolute HTTP- oder HTTPS-Adresse konfigurieren."; if (!credentialConfigured) return "OpenClaw-Token oder Passwort ausschließlich in der Laufzeitkonfiguration hinterlegen."; if (!versionMatches) return "Integrations:OpenClaw:RequiredVersion bewusst an die installierte Gateway-Version anpassen."; if (!connected && !IsLoopbackEndpoint(endpoint)) return "Remote Gateways benötigen eine gepaarte Device-Identity; für den backend helper OpenClaw direkt über Loopback anbinden."; if (!connected) return "OpenClaw Gateway starten und Token/Passwort sowie Port 18789 prüfen."; return null; } private static string BuildPairingRecovery(string? requestId) { var normalizedRequestId = requestId?.Trim(); if (string.IsNullOrWhiteSpace(normalizedRequestId) || normalizedRequestId.Length > 128 || normalizedRequestId.Any(character => !(char.IsAsciiLetterOrDigit(character) || character is '-' or '_' or '.' or ':'))) { return "OpenClaw-Geräteliste prüfen und den aktuellen Nexus-Pairing-Request freigeben."; } return $"Auf dem Gateway 'openclaw devices approve {normalizedRequestId}' nach Prüfung ausführen."; } private static string NormalizeEndpoint(string? value) { if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) return string.IsNullOrWhiteSpace(value) ? "not configured" : "invalid"; var builder = new UriBuilder(uri) { UserName = string.Empty, Password = string.Empty, Query = string.Empty, Fragment = string.Empty }; return builder.Uri.GetLeftPart(UriPartial.Path).TrimEnd('/'); } private static bool IsLoopbackEndpoint(string endpoint) { if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) return false; return uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase); } private static string? SafeConnectionMessage(string? message) { if (string.IsNullOrWhiteSpace(message)) return null; var trimmed = message.Trim(); if (trimmed.Contains("token", StringComparison.OrdinalIgnoreCase) || trimmed.Contains("password", StringComparison.OrdinalIgnoreCase) || trimmed.Length > 240) return "Gateway-Verbindungsdetails sind in den Backend-Logs verfügbar."; return trimmed; } private OpenClawTaskDto MapTask(JsonNode? node) { var status = NormalizeTaskStatus(ReadString(node, "status", "state") ?? "unknown"); return new OpenClawTaskDto( ReadString(node, "id", "taskId") ?? string.Empty, ReadString(node, "title", "name", "summary") ?? "OpenClaw task", status, ReadString(node, "kind", "type"), ReadString(node, "runtime"), ReadString(node, "agentId", "agent"), ReadString(node, "sessionKey"), ReadString(node, "runId"), ReadString(node, "flowId"), ReadString(node, "parentTaskId"), ReadDate(node, "createdAt", "createdAtMs", "queuedAt", "queuedAtMs"), ReadDate(node, "startedAt", "startedAtMs"), ReadDate(node, "updatedAt", "updatedAtMs"), ReadDate(node, "finishedAt", "finishedAtMs", "completedAt", "completedAtMs"), ReadDouble(node, "progress", "progressPercent"), ReadString(node, "terminalSummary", "summary", "result"), ReadString(node, "error", "errorText", "failure"), connector.Supports("tasks.cancel") && HasScope("operator.write") && status is "queued" or "running"); } private static OpenClawSessionDto MapSession(JsonNode? node, bool canAbortMethod) { var key = ReadString(node, "key", "sessionKey") ?? string.Empty; var agentId = ReadString(node, "agentId", "agent") ?? ExtractAgentId(key) ?? "main"; var status = NormalizeSessionStatus( ReadString(node, "status", "runStatus") ?? ReadString(ReadNode(node, "activeRun"), "status") ?? "idle"); var modelNode = ReadNode(node, "model", "resolvedModel"); var model = modelNode is JsonObject ? ReadString(modelNode, "id", "model") : ReadString(node, "model"); var provider = ReadString(node, "provider") ?? ReadString(modelNode, "provider") ?? ExtractProvider(model); var runId = ReadString(node, "runId") ?? ReadString(ReadNode(node, "activeRun"), "runId", "id"); return new OpenClawSessionDto( key, ReadString(node, "sessionId", "id"), agentId, ReadString(node, "title", "label", "displayName", "preview") ?? ShortSessionKey(key), status, ReadString(node, "kind", "chatType"), ReadString(node, "channel", "channelId"), model, provider, runId, ReadDate(node, "updatedAt", "updatedAtMs", "lastInteractionAt", "lastInteractionAtMs"), ReadLong(node, "inputTokens", "input_tokens"), ReadLong(node, "outputTokens", "output_tokens"), ReadLong(node, "totalTokens", "total_tokens"), canAbortMethod && status is "running" or "active"); } private OpenClawCronJobDto MapCronJob(JsonNode? node, bool canRunMethod) { var scheduleNode = ReadNode(node, "schedule"); var stateNode = ReadNode(node, "state"); var enabled = ReadBool(node, "enabled") ?? true; var running = ReadDate(stateNode, "runningAtMs", "runningAt") is not null; var lastStatus = ReadString(stateNode, "lastRunStatus", "lastStatus") ?? ReadString(node, "lastRunStatus", "lastStatus"); var status = !enabled ? "disabled" : running ? "running" : lastStatus?.ToLowerInvariant() switch { "ok" => "ok", "error" => "error", "skipped" => "skipped", _ => "idle" }; return new OpenClawCronJobDto( ReadString(node, "id", "jobId") ?? string.Empty, ReadString(node, "name", "title") ?? "OpenClaw schedule", ReadString(node, "description"), FormatSchedule(scheduleNode), ReadString(scheduleNode, "tz", "timezone"), enabled, status, ReadString(node, "agentId"), ReadString(node, "sessionKey"), ReadDate(stateNode, "nextRunAtMs", "nextRunAt") ?? ReadDate(node, "nextRunAtMs", "nextRunAt"), ReadDate(stateNode, "lastRunAtMs", "lastRunAt") ?? ReadDate(node, "lastRunAtMs", "lastRunAt"), lastStatus, SanitizeCronText( ReadString(stateNode, "lastError", "lastDiagnosticSummary") ?? ReadString(node, "lastRunError", "lastError")), canRunMethod && (AllowCommandCron || !IsRestrictedCronDefinition(node)), node is null ? null : ComputeCronResourceHash(node)); } private OpenClawCronJobDetailDto MapCronJobDetail(JsonNode node) { var schedule = ReadNode(node, "schedule"); var payload = ReadNode(node, "payload"); var delivery = ReadNode(node, "delivery"); var trigger = ReadNode(node, "trigger"); var failureAlert = ReadNode(node, "failureAlert"); var state = ReadNode(node, "state"); var canAdmin = CronManagementEnabled && HasScope("operator.admin"); var restricted = IsRestrictedCronDefinition(node) && !AllowCommandCron; return new OpenClawCronJobDetailDto( ReadString(node, "id", "jobId") ?? string.Empty, ReadString(node, "name", "title") ?? "OpenClaw schedule", ReadString(node, "displayName"), ReadString(node, "description"), ReadBool(node, "enabled") ?? true, ReadBool(node, "deleteAfterRun") ?? false, ReadString(node, "agentId"), ReadString(node, "sessionKey"), ReadString(node, "sessionTarget") ?? "isolated", ReadString(node, "wakeMode") ?? "now", MapCronSchedule(schedule), MapCronPayload(payload), delivery is null ? null : MapCronDelivery(delivery), trigger is null ? null : new OpenClawCronTriggerDto( ReadString(trigger, "script") ?? string.Empty, ReadBool(trigger, "once") ?? false), failureAlert is null || failureAlert is JsonValue ? null : new OpenClawCronFailureAlertDto( ReadInt(failureAlert, "after"), ReadString(failureAlert, "channel"), ReadString(failureAlert, "to"), ReadLong(failureAlert, "cooldownMs"), ReadBool(failureAlert, "includeSkipped"), ReadString(failureAlert, "mode"), ReadString(failureAlert, "accountId")), ReadDate(node, "createdAt", "createdAtMs"), ReadDate(node, "updatedAt", "updatedAtMs"), ReadDate(state, "nextRunAtMs", "nextRunAt") ?? ReadDate(node, "nextRunAtMs", "nextRunAt"), ReadDate(state, "lastRunAtMs", "lastRunAt") ?? ReadDate(node, "lastRunAtMs", "lastRunAt"), ReadString(state, "lastRunStatus", "lastStatus") ?? ReadString(node, "lastRunStatus", "lastStatus"), SanitizeCronText( ReadString(state, "lastError", "lastDiagnosticSummary") ?? ReadString(node, "lastRunError", "lastError")), ComputeCronResourceHash(node), canAdmin && connector.Supports("cron.update"), canAdmin && connector.Supports("cron.remove"), canAdmin && connector.Supports("cron.run") && !restricted); } private static OpenClawCronScheduleDto MapCronSchedule(JsonNode? node) => new( ReadString(node, "kind") ?? "unknown", ReadString(node, "expr"), ReadString(node, "tz", "timezone"), ReadString(node, "at"), ReadLong(node, "everyMs"), ReadLong(node, "anchorMs"), ReadLong(node, "staggerMs"), ReadString(node, "command"), ReadString(node, "cwd")); private static OpenClawCronPayloadDto MapCronPayload(JsonNode? node) { var environment = ReadNode(node, "env") as JsonObject; return new OpenClawCronPayloadDto( ReadString(node, "kind") ?? "unknown", ReadString(node, "text"), ReadString(node, "message"), ReadString(node, "model"), ReadStringArray(ReadNode(node, "fallbacks")), ReadString(node, "thinking"), ReadDouble(node, "timeoutSeconds"), ReadBool(node, "allowUnsafeExternalContent"), ReadBool(node, "lightContext"), ReadStringArray(ReadNode(node, "toolsAllow")), ReadStringArray(ReadNode(node, "argv")), ReadString(node, "cwd"), environment?.Select(property => property.Key) .Order(StringComparer.Ordinal) .ToArray() ?? Array.Empty(), ReadNode(node, "input") is not null, ReadDouble(node, "noOutputTimeoutSeconds"), ReadInt(node, "outputMaxBytes")); } private static OpenClawCronDeliveryDto MapCronDelivery(JsonNode node) { var completion = ReadNode(node, "completionDestination"); var failure = ReadNode(node, "failureDestination"); return new OpenClawCronDeliveryDto( ReadString(node, "mode") ?? "none", ReadString(node, "channel"), ReadString(node, "to"), ReadString(node, "threadId"), ReadString(node, "accountId"), ReadBool(node, "bestEffort"), completion is null ? null : new OpenClawCronDestinationDto( ReadString(completion, "channel"), ReadString(completion, "to"), ReadString(completion, "accountId"), ReadString(completion, "mode")), failure is null ? null : new OpenClawCronDestinationDto( ReadString(failure, "channel"), ReadString(failure, "to"), ReadString(failure, "accountId"), ReadString(failure, "mode"))); } private static OpenClawCronRunDto MapCronRun(JsonNode? node) { var jobId = ReadString(node, "jobId", "id") ?? string.Empty; var runId = ReadString(node, "runId"); var occurredAt = ReadDate(node, "ts", "timestamp", "occurredAt"); var usage = ReadNode(node, "usage"); var diagnostics = ReadNode(node, "diagnostics"); return new OpenClawCronRunDto( runId ?? $"{jobId}:{occurredAt?.ToUnixTimeMilliseconds() ?? 0}", jobId, ReadString(node, "jobName"), runId, (ReadString(node, "status") ?? "unknown").ToLowerInvariant(), ReadString(node, "action") ?? "finished", SanitizeCronText(ReadString(node, "summary")), SanitizeCronText(ReadString(node, "error")), ReadString(node, "errorReason"), ReadString(node, "deliveryStatus"), SanitizeCronText(ReadString(node, "deliveryError")), ReadBool(node, "delivered"), ReadBool(node, "triggerFired"), SanitizeCronText(ReadString(diagnostics, "summary")), ReadItems(diagnostics, "entries") .Select(MapCronRunDiagnostic) .ToArray(), ReadString(node, "sessionId"), ReadString(node, "sessionKey"), occurredAt, ReadDate(node, "runAtMs", "runAt"), ReadLong(node, "durationMs"), ReadDate(node, "nextRunAtMs", "nextRunAt"), ReadString(node, "model"), ReadString(node, "provider"), ReadLong(usage, "input_tokens", "inputTokens"), ReadLong(usage, "output_tokens", "outputTokens"), ReadLong(usage, "total_tokens", "totalTokens")); } private static OpenClawCronRunDiagnosticDto MapCronRunDiagnostic(JsonNode? node) => new( ReadDate(node, "ts", "timestamp"), ReadString(node, "source") ?? "cron", ReadString(node, "severity") ?? "info", SanitizeCronText(ReadString(node, "message")) ?? "Cron diagnostic", ReadString(node, "toolName"), ReadDouble(node, "exitCode"), ReadBool(node, "truncated") ?? false); private static string? SanitizeCronText(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var sanitized = DeliveryTargetPattern.Replace(value.Trim(), "[redacted-target]"); return sanitized.Length <= 1000 ? sanitized : $"{sanitized[..997]}..."; } private static OpenClawActivityDto MapActivity(JsonNode? node) { var eventType = ReadString(node, "eventType", "type") ?? "activity"; var action = ReadString(node, "action") ?? eventType; var status = ReadString(node, "status", "outcome") ?? "recorded"; var id = ReadString(node, "eventId", "id") ?? $"{eventType}:{ReadLong(node, "sequence") ?? 0}"; var message = ReadString(node, "message", "summary", "description") ?? $"{action} · {status}"; return new OpenClawActivityDto( id, eventType, ReadString(node, "kind") ?? "gateway", action, status, message, ReadString(node, "severity"), ReadString(node, "actor", "actorId"), ReadString(node, "agentId"), ReadString(node, "sessionKey"), ReadString(node, "runId"), ReadDate(node, "occurredAt", "occurredAtMs", "timestamp", "ts"), "openclaw-audit"); } private static OpenClawActivityDto MapGatewayEvent(GatewayEventEnvelope envelope) { var payload = envelope.Payload; var status = ReadString(payload, "status", "state") ?? "event"; return new OpenClawActivityDto( $"{envelope.Event}:{envelope.Sequence?.ToString(CultureInfo.InvariantCulture) ?? envelope.ReceivedAt.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)}", envelope.Event, ReadString(payload, "kind") ?? "gateway-event", ReadString(payload, "action") ?? envelope.Event, status, ReadString(payload, "message", "summary") ?? $"Gateway event: {envelope.Event}", ReadString(payload, "severity"), ReadString(payload, "actor", "actorId"), ReadString(payload, "agentId"), ReadString(payload, "sessionKey"), ReadString(payload, "runId"), envelope.ReceivedAt, "openclaw-event-stream"); } private static OpenClawApprovalDto MapApproval(JsonNode? node, bool canResolve) { var kind = ReadString(node, "kind", "approvalKind", "type") ?? "exec"; var id = ReadString(node, "id", "approvalId", "requestId") ?? string.Empty; var status = ReadString(node, "status", "state") ?? "pending"; var allowed = ReadStringArray(ReadNode(node, "allowedDecisions")); if (allowed.Count == 0) allowed = ApprovalDecisions; return new OpenClawApprovalDto( id, kind, ReadString(node, "title", "command", "toolName") ?? "OpenClaw approval", ReadString(node, "description", "reason", "message"), status.ToLowerInvariant(), (ReadString(node, "severity") ?? "warning").ToLowerInvariant(), ReadString(node, "command", "rawCommand", "commandText"), ReadString(node, "cwd", "workingDirectory"), ReadString(node, "agentId"), ReadString(node, "sessionKey"), ReadDate(node, "requestedAt", "requestedAtMs", "createdAt", "createdAtMs"), ReadDate(node, "expiresAt", "expiresAtMs"), allowed, canResolve && string.Equals(status, "pending", StringComparison.OrdinalIgnoreCase)); } private static OpenClawModelDto MapModel(JsonNode? node) { var id = ReadString(node, "id", "model", "modelId") ?? string.Empty; var provider = ReadString(node, "provider", "providerId") ?? ExtractProvider(id) ?? "unknown"; var configured = ReadBool(node, "configured", "isConfigured") ?? true; var available = ReadBool(node, "available", "eligible", "isAvailable") ?? configured; return new OpenClawModelDto( id, ReadString(node, "name", "label", "displayName") ?? id, provider, configured, available, ReadInt(node, "contextWindow", "contextLength", "maxTokens"), ReadString(node, "reason", "unavailableReason")); } private static OpenClawModelAuthProviderDto MapModelAuthProvider(JsonNode? node) { var provider = SafeModelAuthText( ReadString(node, "provider"), fallback: "unknown", maxLength: 96); var displayName = SafeModelAuthText( ReadString(node, "displayName"), fallback: provider, maxLength: 120); var status = NormalizeModelAuthStatus(ReadString(node, "status")); var profiles = ReadItems(node, "profiles") .Select(profile => new { Type = NormalizeModelAuthProfileType(ReadString(profile, "type")), Status = NormalizeModelAuthStatus(ReadString(profile, "status")) }) .GroupBy(profile => (profile.Type, profile.Status)) .Select(group => new OpenClawModelAuthProfileSummaryDto( group.Key.Type, group.Key.Status, group.Count())) .OrderBy(profile => profile.Type, StringComparer.Ordinal) .ThenBy(profile => profile.Status, StringComparer.Ordinal) .ToArray(); return new OpenClawModelAuthProviderDto( provider, displayName, status, MapModelAuthExpiry(ReadNode(node, "expiry")), profiles, MapModelAuthApiKey(ReadNode(node, "apiKey")), MapModelAuthUsage(ReadNode(node, "usage"))); } private static OpenClawModelAuthExpiryDto? MapModelAuthExpiry(JsonNode? node) { var at = ReadDate(node, "at"); var remainingMs = ReadLong(node, "remainingMs"); if (at is null || remainingMs is null) return null; return new OpenClawModelAuthExpiryDto( at.Value, remainingMs.Value, FormatModelAuthRemaining(remainingMs.Value)); } private static OpenClawModelAuthApiKeyDto? MapModelAuthApiKey(JsonNode? node) { var source = ReadString(node, "source")?.ToLowerInvariant(); if (source is not ("config" or "env")) return null; var envVar = source == "env" ? ReadString(node, "envVar") : null; if (envVar is not null && !SafeEnvironmentVariablePattern.IsMatch(envVar)) envVar = null; return new OpenClawModelAuthApiKeyDto(source, envVar); } private static OpenClawModelAuthUsageDto? MapModelAuthUsage(JsonNode? node) { var summary = SafeUsageText(ReadString(node, "summary"), 160); var plan = SafeUsageText(ReadString(node, "plan"), 80); return summary is null && plan is null ? null : new OpenClawModelAuthUsageDto(summary, plan); } private static string NormalizeModelAuthStatus(string? value) { var normalized = value?.Trim().ToLowerInvariant(); return normalized is not null && ModelAuthStatuses.Contains(normalized) ? normalized : "unknown"; } private static string NormalizeModelAuthProfileType(string? value) { var normalized = value?.Trim().ToLowerInvariant(); return normalized is not null && ModelAuthProfileTypes.Contains(normalized) ? normalized : "unknown"; } private static string SafeModelAuthText( string? value, string fallback, int maxLength) { if (string.IsNullOrWhiteSpace(value)) return fallback; var safe = new string(value .Where(character => !char.IsControl(character)) .ToArray()) .Trim(); if (safe.Length == 0) return fallback; return safe.Length <= maxLength ? safe : safe[..maxLength]; } private static string? SafeUsageText(string? value, int maxLength) { if (string.IsNullOrWhiteSpace(value)) return null; var safe = string.Join( " ", value.Split( (char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); if (safe.Length == 0 || SensitiveUsageTextPattern.IsMatch(safe)) return null; return safe.Length <= maxLength ? safe : safe[..maxLength]; } private static string FormatModelAuthRemaining(long remainingMs) { var expired = remainingMs < 0; var absoluteMs = Math.Abs((double)remainingMs); var totalDays = absoluteMs / 86_400_000d; var totalHours = absoluteMs / 3_600_000d; var totalMinutes = absoluteMs / 60_000d; var value = totalDays >= 1 ? $"{Math.Floor(totalDays):0}d" : totalHours >= 1 ? $"{Math.Floor(totalHours):0}h" : $"{Math.Max(0, Math.Floor(totalMinutes)):0}m"; return expired ? $"-{value}" : value; } private static OpenClawAgentDto MapAgent(JsonNode? node) { var id = ReadString(node, "id", "agentId") ?? string.Empty; var modelNode = ReadNode(node, "model", "effectiveModel"); var model = modelNode is JsonObject ? ReadString(modelNode, "id", "model") : ReadString(node, "model"); return new OpenClawAgentDto( id, ReadString(node, "name", "label", "displayName") ?? id, ReadString(node, "description", "role"), model, ReadString(node, "provider") ?? ReadString(modelNode, "provider") ?? ExtractProvider(model), ReadString(node, "workspace", "workspaceDir", "cwd"), (ReadString(node, "status", "state") ?? "configured").ToLowerInvariant()); } private static string FormatSchedule(JsonNode? schedule) { var kind = ReadString(schedule, "kind"); return kind switch { "cron" => ReadString(schedule, "expr") ?? "cron", "every" => FormatEvery(ReadLong(schedule, "everyMs")), "at" => ReadString(schedule, "at") ?? "one-time", "on-exit" => "on-exit", _ => ReadString(schedule, "expr", "value") ?? "unknown" }; } private static string FormatEvery(long? milliseconds) { if (milliseconds is null) return "interval"; var duration = TimeSpan.FromMilliseconds(milliseconds.Value); if (duration.TotalDays >= 1 && duration.TotalDays % 1 == 0) return $"every {duration.TotalDays:0}d"; if (duration.TotalHours >= 1 && duration.TotalHours % 1 == 0) return $"every {duration.TotalHours:0}h"; if (duration.TotalMinutes >= 1 && duration.TotalMinutes % 1 == 0) return $"every {duration.TotalMinutes:0}m"; return $"every {duration.TotalSeconds:0}s"; } private static string NormalizeTaskStatus(string status) { return status.Trim().ToLowerInvariant() switch { "completed" or "complete" or "success" or "ok" => "succeeded", "canceled" => "cancelled", "timedout" or "timeout" => "timed_out", var value => value }; } private static string NormalizeSessionStatus(string status) { return status.Trim().ToLowerInvariant() switch { "in_progress" or "in-progress" => "running", "completed" or "succeeded" or "failed" or "cancelled" => "idle", var value => value }; } private static string? ExtractAgentId(string sessionKey) { var parts = sessionKey.Split(':', StringSplitOptions.RemoveEmptyEntries); var index = Array.FindIndex(parts, part => string.Equals(part, "agent", StringComparison.OrdinalIgnoreCase)); return index >= 0 && index + 1 < parts.Length ? parts[index + 1] : null; } private static string ShortSessionKey(string key) { if (string.IsNullOrWhiteSpace(key)) return "OpenClaw session"; return key.Length <= 56 ? key : $"…{key[^55..]}"; } private static string? ExtractProvider(string? model) { if (string.IsNullOrWhiteSpace(model)) return null; var slash = model.IndexOf('/'); return slash > 0 ? model[..slash] : null; } private static IReadOnlyList ReadItems(JsonNode? response, params string[] keys) { if (response is JsonArray direct) return direct.Where(item => item is not null).Select(item => item!).ToArray(); foreach (var key in keys) { if (ReadNode(response, key) is JsonArray array) return array.Where(item => item is not null).Select(item => item!).ToArray(); } return Array.Empty(); } private static JsonNode? ReadNode(JsonNode? node, params string[] keys) { if (node is not JsonObject obj) return null; foreach (var key in keys) { var property = obj.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase)); if (property.Value is not null) return property.Value; } return null; } private static string? ReadString(JsonNode? node, params string[] keys) { var value = keys.Length == 0 ? node : ReadNode(node, keys); if (value is not JsonValue jsonValue) return null; try { if (jsonValue.TryGetValue(out var text)) return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); return jsonValue.ToJsonString().Trim('"'); } catch { return null; } } private static bool? ReadBool(JsonNode? node, params string[] keys) { var value = ReadNode(node, keys); if (value is not JsonValue jsonValue) return null; try { if (jsonValue.TryGetValue(out var result)) return result; if (jsonValue.TryGetValue(out var text) && bool.TryParse(text, out result)) return result; } catch { // Ignore incompatible Gateway values and keep the stable facade nullable. } return null; } private static int? ReadInt(JsonNode? node, params string[] keys) { var number = ReadLong(node, keys); return number is >= int.MinValue and <= int.MaxValue ? (int)number.Value : null; } private static long? ReadLong(JsonNode? node, params string[] keys) { var value = ReadNode(node, keys); if (value is not JsonValue jsonValue) return null; try { if (jsonValue.TryGetValue(out var number)) return number; if (jsonValue.TryGetValue(out var doubleNumber)) return Convert.ToInt64(doubleNumber); if (jsonValue.TryGetValue(out var text) && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out number)) return number; } catch { // Ignore incompatible Gateway values and keep the stable facade nullable. } return null; } private static double? ReadDouble(JsonNode? node, params string[] keys) { var value = ReadNode(node, keys); if (value is not JsonValue jsonValue) return null; try { if (jsonValue.TryGetValue(out var number)) return number; if (jsonValue.TryGetValue(out var text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number)) return number; } catch { // Ignore incompatible Gateway values and keep the stable facade nullable. } return null; } private static DateTimeOffset? ReadDate(JsonNode? node, params string[] keys) { foreach (var key in keys) { var value = ReadNode(node, key); if (value is not JsonValue jsonValue) continue; try { if (jsonValue.TryGetValue(out var numeric)) { return numeric > 10_000_000_000 ? DateTimeOffset.FromUnixTimeMilliseconds(numeric) : DateTimeOffset.FromUnixTimeSeconds(numeric); } if (jsonValue.TryGetValue(out var doubleNumeric)) return DateTimeOffset.FromUnixTimeMilliseconds(Convert.ToInt64(doubleNumeric)); if (jsonValue.TryGetValue(out var text)) { if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out numeric)) { return numeric > 10_000_000_000 ? DateTimeOffset.FromUnixTimeMilliseconds(numeric) : DateTimeOffset.FromUnixTimeSeconds(numeric); } if (DateTimeOffset.TryParse( text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) return parsed; } } catch { // Ignore malformed dates from newer or plugin-owned payloads. } } return null; } private static IReadOnlyList ReadStringArray(JsonNode? node) { if (node is not JsonArray array) return Array.Empty(); return array .Select(item => ReadString(item)) .Where(item => !string.IsNullOrWhiteSpace(item)) .Select(item => item!) .Distinct(StringComparer.Ordinal) .ToArray(); } private sealed record OpenClawCapabilityDefinition( string Id, string Label, string Method, string RequiredScope); private sealed record OpenClawFailure( string State, string Message, string Recovery); }