using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Nexus.Api.Models; namespace Nexus.Api.Services; /// /// Browser-safe façade for OpenClaw-owned agent workspace and configuration data. /// Nexus never resolves or opens an OpenClaw host path here; all access goes through /// the authenticated Gateway RPC connection. /// public sealed class OpenClawAgentConfigurationService( IGatewayConnector connector, IOpenClawOperationAuditStore auditStore, IOpenClawWriteGate writeGate, ILogger logger) : IOpenClawAgentConfigurationService { public const string MissingContentHash = "missing"; private const int MaxAgentIdLength = 128; private const int MaxAgentFileBytes = 1_048_576; private const int MaxConfigPatchBytes = 1_048_576; private const int MaxWorkspacePathLength = 1024; private const int MaxConfigPathLength = 1024; private static readonly string[] StandardFileNames = [ "AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md", "HEARTBEAT.md", "BOOTSTRAP.md", "MEMORY.md" ]; private static readonly IReadOnlyDictionary CanonicalFileNames = StandardFileNames.ToDictionary(name => name, name => name, StringComparer.OrdinalIgnoreCase); private static readonly string[] SensitiveWorkspaceNameFragments = [ "auth-profile", "credential", "private-key", "private_key", "secret", "token" ]; private static readonly string[] SensitiveConfigKeyFragments = [ "access-token", "access_token", "accesstoken", "api-key", "api_key", "apikey", "auth-token", "auth_token", "authtoken", "credential", "password", "private-key", "private_key", "privatekey", "refresh-token", "refresh_token", "refreshtoken", "secret", "token" ]; public async Task GetAgentFilesAsync( string agentId, CancellationToken cancellationToken = default) { EnsureAvailable("agents.files.list", "operator.read"); var normalizedAgentId = NormalizeAgentId(agentId); var response = await connector.InvokeAsync( "agents.files.list", new JsonObject { ["agentId"] = normalizedAgentId }, cancellationToken: cancellationToken); var files = ReadArray(response, "files") .Select(MapAgentFileSummary) .Where(file => file is not null) .Select(file => file!) .OrderBy(file => Array.IndexOf(StandardFileNames, file.Name)) .ToArray(); return new OpenClawAgentFileCollectionDto( normalizedAgentId, files, DateTimeOffset.UtcNow); } public Task GetAgentFileAsync( string agentId, string fileName, CancellationToken cancellationToken = default) { EnsureAvailable("agents.files.get", "operator.read"); return GetAgentFileCoreAsync( NormalizeAgentId(agentId), NormalizeStandardFileName(fileName), cancellationToken); } public async Task SetAgentFileAsync( string agentId, string fileName, UpdateOpenClawAgentFileRequest request, OpenClawInvocationContext invocationContext, CancellationToken cancellationToken = default) { await EnsureWriteAvailableAsync( "agents.files.set", cancellationToken); var normalizedAgentId = NormalizeAgentId(agentId); var normalizedFileName = NormalizeStandardFileName(fileName); ValidateAgentFileWrite(request); var expectedHash = NormalizeExpectedHash(request.ExpectedHash); var desiredHash = HashContent(request.Content); var descriptor = new OpenClawOperationDescriptor( "agents.files.set", "agent-file", $"{normalizedAgentId}/{normalizedFileName}", HashContent( $"agents.files.set\n{normalizedAgentId}\n{normalizedFileName}\n{expectedHash}\n{desiredHash}")); var claim = await auditStore.ClaimAsync( invocationContext, descriptor, cancellationToken); if (claim.Disposition != OpenClawOperationClaimDisposition.Started) { if (claim.Disposition == OpenClawOperationClaimDisposition.Replayed && claim.PreviousOk == true) { var replayed = await GetAgentFileCoreAsync( normalizedAgentId, normalizedFileName, cancellationToken); if (!replayed.Missing && string.Equals(replayed.ContentHash, desiredHash, StringComparison.Ordinal)) { return BuildAgentFileWriteResult( replayed, invocationContext, state: "replayed", message: "Die bereits bestätigte Dateiänderung wurde nicht erneut ausgeführt."); } throw new OpenClawAgentConfigurationConflictException( "idempotency_state_drift", "Die frühere Änderung war erfolgreich, die Datei hat sich danach jedoch erneut geändert.", desiredHash, replayed.ContentHash); } throw BuildIdempotencyConflict(claim); } var auditCompleted = false; try { var current = await GetAgentFileCoreAsync( normalizedAgentId, normalizedFileName, cancellationToken); if (!string.Equals(current.ContentHash, expectedHash, StringComparison.OrdinalIgnoreCase)) { await CompleteAuditAsync( invocationContext, descriptor, ok: false, state: "conflict", message: "Datei wurde seit dem letzten Lesen verändert.", errorCode: "content_hash_mismatch", cancellationToken); auditCompleted = true; throw new OpenClawAgentConfigurationConflictException( "content_hash_mismatch", "Die Datei wurde seit dem letzten Lesen verändert.", expectedHash, current.ContentHash); } var setResponse = await connector.InvokeAsync( "agents.files.set", new JsonObject { ["agentId"] = normalizedAgentId, ["name"] = normalizedFileName, ["content"] = request.Content }, cancellationToken: cancellationToken, invocationContext: invocationContext with { IncludeIdempotencyParameter = false }); if (ReadBool(setResponse, "ok") == false) { throw new OpenClawAgentConfigurationVerificationException( "OpenClaw hat die Dateiänderung nicht bestätigt."); } var verified = await GetAgentFileCoreAsync( normalizedAgentId, normalizedFileName, cancellationToken); if (verified.Missing || !string.Equals(verified.ContentHash, desiredHash, StringComparison.Ordinal) || !string.Equals(verified.Content, request.Content, StringComparison.Ordinal)) { throw new OpenClawAgentConfigurationVerificationException( "Die Datei konnte nach dem Schreiben nicht identisch zurückgelesen werden."); } await CompleteAuditAsync( invocationContext, descriptor, ok: true, state: "completed", message: "Datei gespeichert und identisch zurückgelesen.", errorCode: null, cancellationToken); auditCompleted = true; return BuildAgentFileWriteResult( verified, invocationContext, state: "completed", message: "Datei gespeichert und identisch zurückgelesen."); } catch { if (!auditCompleted) { await TryCompleteFailedAuditAsync( invocationContext, descriptor, "in_doubt", "Dateiänderung konnte nicht vollständig verifiziert werden."); } throw; } } public async Task GetWorkspaceAsync( string agentId, string? path, int offset, int limit, CancellationToken cancellationToken = default) { EnsureAvailable("agents.workspace.list", "operator.read"); var normalizedAgentId = NormalizeAgentId(agentId); var normalizedPath = NormalizeWorkspacePath(path, allowEmpty: true); if (offset < 0) throw new OpenClawAgentConfigurationValidationException("offset", "Offset must not be negative."); if (limit is < 1 or > 500) throw new OpenClawAgentConfigurationValidationException("limit", "Limit must be between 1 and 500."); var parameters = new JsonObject { ["agentId"] = normalizedAgentId, ["path"] = normalizedPath, ["offset"] = offset, ["limit"] = limit }; var response = await connector.InvokeAsync( "agents.workspace.list", parameters, cancellationToken: cancellationToken); var entries = ReadArray(response, "entries") .Select(MapWorkspaceEntry) .Where(entry => entry is not null) .Select(entry => entry!) .ToArray(); var responsePath = ReadString(response, "path"); if (!TryNormalizeWorkspacePath(responsePath, allowEmpty: true, out var safeResponsePath)) safeResponsePath = normalizedPath; var parentPath = ReadString(response, "parentPath"); if (!TryNormalizeWorkspacePath(parentPath, allowEmpty: true, out var safeParentPath)) safeParentPath = null; return new OpenClawWorkspaceCollectionDto( normalizedAgentId, safeResponsePath!, string.IsNullOrEmpty(safeParentPath) ? null : safeParentPath, entries, ReadInt(response, "totalEntries") ?? entries.Length, ReadInt(response, "offset") ?? offset, DateTimeOffset.UtcNow); } public async Task GetWorkspaceFileAsync( string agentId, string path, CancellationToken cancellationToken = default) { EnsureAvailable("agents.workspace.get", "operator.read"); var normalizedAgentId = NormalizeAgentId(agentId); var normalizedPath = NormalizeWorkspacePath(path, allowEmpty: false); var response = await connector.InvokeAsync( "agents.workspace.get", new JsonObject { ["agentId"] = normalizedAgentId, ["path"] = normalizedPath }, cancellationToken: cancellationToken); var file = response?["file"] ?? throw new OpenClawAgentConfigurationVerificationException( "OpenClaw hat keine Workspace-Datei zurückgegeben."); var responsePath = ReadString(file, "path"); if (!TryNormalizeWorkspacePath(responsePath, allowEmpty: false, out var safeResponsePath) || !string.Equals(safeResponsePath, normalizedPath, StringComparison.Ordinal)) { throw new OpenClawAgentConfigurationVerificationException( "OpenClaw hat eine Datei außerhalb des angefragten Workspace-Pfads zurückgegeben."); } var content = ReadString(file, "content") ?? string.Empty; var encoding = ReadString(file, "encoding") ?? "utf8"; if (encoding is not ("utf8" or "base64")) { throw new OpenClawAgentConfigurationVerificationException( "OpenClaw hat eine nicht unterstützte Workspace-Kodierung zurückgegeben."); } return new OpenClawWorkspaceFileDto( normalizedAgentId, safeResponsePath!, ReadString(file, "name") ?? Path.GetFileName(normalizedPath), ReadLong(file, "size") ?? Encoding.UTF8.GetByteCount(content), FromUnixMilliseconds(ReadLong(file, "updatedAtMs")), ReadString(file, "mimeType") ?? "text/plain", encoding, content, HashContent(content), DateTimeOffset.UtcNow); } public async Task GetConfigSchemaAsync( string path, CancellationToken cancellationToken = default) { EnsureAvailable("config.schema.lookup", "operator.read"); var normalizedPath = NormalizeConfigPath(path, "path"); var response = await connector.InvokeAsync( "config.schema.lookup", new JsonObject { ["path"] = normalizedPath }, cancellationToken: cancellationToken); var children = ReadArray(response, "children") .Select(child => new OpenClawConfigSchemaChildDto( ReadString(child, "key") ?? string.Empty, ReadString(child, "path") ?? string.Empty, SanitizePayload(child?["type"]), ReadBool(child, "required") ?? false, ReadBool(child, "hasChildren") ?? false, ReadString(child, "reloadKind"), SanitizePayload(child?["hint"]))) .Where(child => !string.IsNullOrWhiteSpace(child.Key) && !string.IsNullOrWhiteSpace(child.Path)) .ToArray(); return new OpenClawConfigSchemaLookupDto( ReadString(response, "path") ?? normalizedPath, SanitizePayload(response?["schema"]), ReadString(response, "reloadKind"), SanitizePayload(response?["hint"]), children, DateTimeOffset.UtcNow); } public Task GetConfigAsync( CancellationToken cancellationToken = default) { EnsureAvailable("config.get", "operator.read"); return GetConfigCoreAsync(cancellationToken); } public async Task PatchConfigAsync( PatchOpenClawConfigRequest request, OpenClawInvocationContext invocationContext, CancellationToken cancellationToken = default) { await EnsureWriteAvailableAsync( "config.patch", cancellationToken); ValidateConfigPatch(request); var baseHash = NormalizeContentHash(request.BaseHash, "baseHash", allowMissing: false); var raw = request.Patch.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); if (Encoding.UTF8.GetByteCount(raw) > MaxConfigPatchBytes) { throw new OpenClawAgentConfigurationValidationException( "patch", $"Config patch must not exceed {MaxConfigPatchBytes} UTF-8 bytes."); } var replacePaths = NormalizeReplacePaths(request.ReplacePaths); var descriptor = new OpenClawOperationDescriptor( "config.patch", "openclaw-config", "primary", HashContent( $"config.patch\n{baseHash}\n{HashContent(raw)}\n{string.Join('\n', replacePaths)}")); var claim = await auditStore.ClaimAsync( invocationContext, descriptor, cancellationToken); if (claim.Disposition != OpenClawOperationClaimDisposition.Started) { if (claim.Disposition == OpenClawOperationClaimDisposition.Replayed && claim.PreviousOk == true) { var replayed = await GetConfigCoreAsync(cancellationToken); return BuildConfigPatchResult( replayed, restart: null, invocationContext, state: "replayed", message: "Die bereits protokollierte Konfigurationsänderung wurde nicht erneut ausgeführt. Der aktuelle Snapshot wurde geladen, das ursprüngliche Read-back lässt sich aus dem metadata-only Audit nicht erneut beweisen.", verified: false); } throw BuildIdempotencyConflict(claim); } var auditCompleted = false; try { var before = await GetConfigCoreAsync(cancellationToken); if (string.IsNullOrWhiteSpace(before.Hash) || !string.Equals(before.Hash, baseHash, StringComparison.OrdinalIgnoreCase)) { await CompleteAuditAsync( invocationContext, descriptor, ok: false, state: "conflict", message: "OpenClaw-Konfiguration wurde seit dem letzten Lesen verändert.", errorCode: "base_hash_mismatch", cancellationToken); auditCompleted = true; throw new OpenClawAgentConfigurationConflictException( "base_hash_mismatch", "OpenClaw-Konfiguration wurde seit dem letzten Lesen verändert.", baseHash, before.Hash); } var parameters = new JsonObject { ["raw"] = raw, ["baseHash"] = baseHash }; if (replacePaths.Count > 0) parameters["replacePaths"] = new JsonArray( replacePaths.Select(path => JsonValue.Create(path)).ToArray()); if (!string.IsNullOrWhiteSpace(request.Note)) parameters["note"] = request.Note.Trim(); if (request.RestartDelayMs is { } restartDelayMs) parameters["restartDelayMs"] = restartDelayMs; var patchResponse = await connector.InvokeAsync( "config.patch", parameters, cancellationToken: cancellationToken, invocationContext: invocationContext with { IncludeIdempotencyParameter = false }); if (ReadBool(patchResponse, "ok") == false) { throw new OpenClawAgentConfigurationVerificationException( "OpenClaw hat die Konfigurationsänderung nicht bestätigt."); } var after = await GetConfigCoreAsync(cancellationToken); var noop = ReadBool(patchResponse, "noop") == true; if (!after.Valid || string.IsNullOrWhiteSpace(after.Hash) || (!noop && string.Equals(after.Hash, before.Hash, StringComparison.OrdinalIgnoreCase))) { throw new OpenClawAgentConfigurationVerificationException( "Die geänderte OpenClaw-Konfiguration konnte nicht gültig zurückgelesen werden."); } await CompleteAuditAsync( invocationContext, descriptor, ok: true, state: noop ? "noop" : "completed", message: noop ? "Konfiguration war bereits aktuell und wurde gültig zurückgelesen." : "Konfiguration geändert und gültig zurückgelesen.", errorCode: null, cancellationToken); auditCompleted = true; return BuildConfigPatchResult( after, SanitizePayload(patchResponse?["restart"]), invocationContext, noop ? "noop" : "completed", noop ? "Konfiguration war bereits aktuell und wurde gültig zurückgelesen." : "Konfiguration geändert und gültig zurückgelesen."); } catch { if (!auditCompleted) { await TryCompleteFailedAuditAsync( invocationContext, descriptor, "in_doubt", "Konfigurationsänderung konnte nicht vollständig verifiziert werden."); } throw; } } private async Task GetAgentFileCoreAsync( string agentId, string fileName, CancellationToken cancellationToken) { var response = await connector.InvokeAsync( "agents.files.get", new JsonObject { ["agentId"] = agentId, ["name"] = fileName }, cancellationToken: cancellationToken); var file = response?["file"] ?? throw new OpenClawAgentConfigurationVerificationException( "OpenClaw hat keine Agent-Datei zurückgegeben."); var responseName = NormalizeStandardFileName(ReadString(file, "name") ?? fileName); if (!string.Equals(responseName, fileName, StringComparison.Ordinal)) { throw new OpenClawAgentConfigurationVerificationException( "OpenClaw hat eine andere als die angefragte Agent-Datei zurückgegeben."); } var missing = ReadBool(file, "missing") ?? false; var content = missing ? null : ReadString(file, "content") ?? string.Empty; return new OpenClawAgentFileDto( agentId, fileName, missing, ReadLong(file, "size"), FromUnixMilliseconds(ReadLong(file, "updatedAtMs")), content, missing ? MissingContentHash : HashContent(content!), DateTimeOffset.UtcNow); } private async Task GetConfigCoreAsync( CancellationToken cancellationToken) { var response = await connector.InvokeAsync( "config.get", new JsonObject(), cancellationToken: cancellationToken); return new OpenClawConfigSnapshotDto( ReadBool(response, "exists") ?? true, ReadBool(response, "valid") ?? false, ReadString(response, "hash"), SanitizePayload(response?["config"]), SanitizePayload(response?["issues"]), SanitizePayload(response?["warnings"]), DateTimeOffset.UtcNow); } private void EnsureAvailable(string method, string requiredScope) { if (connector.ConnectionState != GatewayConnectionState.Connected) { throw new OpenClawAgentConfigurationUnavailableException( "disconnected", method, requiredScope, "OpenClaw Gateway ist nicht verbunden."); } if (!connector.Supports(method)) { throw new OpenClawAgentConfigurationUnavailableException( "unsupported", method, requiredScope, $"OpenClaw bietet {method} nicht an."); } if (!HasScope(requiredScope)) { throw new OpenClawAgentConfigurationUnavailableException( "forbidden", method, requiredScope, $"OpenClaw hat den Scope {requiredScope} nicht gewährt."); } } private bool HasScope(string requiredScope) { var scopes = connector.GrantedScopes; if (scopes.Contains("operator.admin")) return true; if (scopes.Contains(requiredScope)) return true; return requiredScope == "operator.read" && scopes.Contains("operator.write"); } private static string NormalizeAgentId(string? agentId) { var value = agentId?.Trim(); if (string.IsNullOrWhiteSpace(value) || value.Length > MaxAgentIdLength) { throw new OpenClawAgentConfigurationValidationException( "agentId", $"Agent id is required and must contain at most {MaxAgentIdLength} characters."); } if (!char.IsAsciiLetterOrDigit(value[0]) || value.Any(character => !(char.IsAsciiLetterOrDigit(character) || character is '-' or '_')) || !string.Equals(value, value.ToLowerInvariant(), StringComparison.Ordinal)) { throw new OpenClawAgentConfigurationValidationException( "agentId", "Agent id may contain lowercase ASCII letters, digits, hyphens, and underscores only."); } return value; } private static string NormalizeStandardFileName(string? fileName) { var value = fileName?.Trim(); if (string.IsNullOrWhiteSpace(value) || !CanonicalFileNames.TryGetValue(value, out var canonical)) { throw new OpenClawAgentConfigurationValidationException( "fileName", $"File name must be one of: {string.Join(", ", StandardFileNames)}."); } return canonical; } private static void ValidateAgentFileWrite(UpdateOpenClawAgentFileRequest? request) { if (request is null || request.Content is null) { throw new OpenClawAgentConfigurationValidationException( "content", "Content is required."); } if (Encoding.UTF8.GetByteCount(request.Content) > MaxAgentFileBytes) { throw new OpenClawAgentConfigurationValidationException( "content", $"Content must not exceed {MaxAgentFileBytes} UTF-8 bytes."); } } private static string NormalizeExpectedHash(string? hash) => NormalizeContentHash(hash, "expectedHash", allowMissing: true); private static string NormalizeContentHash(string? hash, string field, bool allowMissing) { var value = hash?.Trim(); if (allowMissing && string.Equals(value, MissingContentHash, StringComparison.OrdinalIgnoreCase)) return MissingContentHash; if (value is null || value.Length != 64 || value.Any(character => !Uri.IsHexDigit(character))) { throw new OpenClawAgentConfigurationValidationException( field, allowMissing ? $"{field} must be a SHA-256 hash or '{MissingContentHash}'." : $"{field} must be a SHA-256 hash."); } return value.ToLowerInvariant(); } private static string NormalizeWorkspacePath(string? path, bool allowEmpty) { if (!TryNormalizeWorkspacePath(path, allowEmpty, out var normalized)) { throw new OpenClawAgentConfigurationValidationException( "path", "Workspace path must be a safe, workspace-relative path."); } return normalized!; } private static bool TryNormalizeWorkspacePath( string? path, bool allowEmpty, out string? normalized) { normalized = null; var value = path?.Trim() ?? string.Empty; if (value.Length == 0) { if (allowEmpty) normalized = string.Empty; return allowEmpty; } if (value.Length > MaxWorkspacePathLength || value.StartsWith('/') || value.StartsWith('\\') || value.StartsWith('~') || value.Contains('\\') || value.Contains('\0') || value.Contains("//", StringComparison.Ordinal) || value.Any(char.IsControl)) { return false; } var segments = value.Split('/'); if (segments.Any(segment => string.IsNullOrWhiteSpace(segment) || segment is "." or ".." || segment.StartsWith('.') || segment.Contains(':') || IsSensitiveWorkspaceName(segment))) { return false; } normalized = string.Join('/', segments); return true; } private static bool IsSensitiveWorkspaceName(string value) { var normalized = value.ToLowerInvariant(); if (normalized is ".env" or "id_rsa" or "id_ed25519" or "credentials.json" || normalized.EndsWith(".pem", StringComparison.Ordinal) || normalized.EndsWith(".key", StringComparison.Ordinal) || normalized.EndsWith(".p12", StringComparison.Ordinal) || normalized.EndsWith(".pfx", StringComparison.Ordinal)) { return true; } return SensitiveWorkspaceNameFragments.Any(fragment => normalized.Contains(fragment, StringComparison.Ordinal)); } private static OpenClawWorkspaceEntryDto? MapWorkspaceEntry(JsonNode? entry) { var path = ReadString(entry, "path"); var name = ReadString(entry, "name"); var kind = ReadString(entry, "kind"); if (!TryNormalizeWorkspacePath(path, allowEmpty: false, out var safePath) || string.IsNullOrWhiteSpace(name) || IsSensitiveWorkspaceName(name) || kind is not ("file" or "directory")) { return null; } return new OpenClawWorkspaceEntryDto( safePath!, name, kind, ReadLong(entry, "size"), FromUnixMilliseconds(ReadLong(entry, "updatedAtMs"))); } private static OpenClawAgentFileSummaryDto? MapAgentFileSummary(JsonNode? file) { var rawName = ReadString(file, "name"); if (string.IsNullOrWhiteSpace(rawName) || !CanonicalFileNames.TryGetValue(rawName, out var name)) { return null; } var missing = ReadBool(file, "missing") ?? false; var content = ReadString(file, "content"); return new OpenClawAgentFileSummaryDto( name, missing, ReadLong(file, "size"), FromUnixMilliseconds(ReadLong(file, "updatedAtMs")), missing ? MissingContentHash : content is null ? null : HashContent(content)); } private static void ValidateConfigPatch(PatchOpenClawConfigRequest? request) { if (request?.Patch is not JsonObject) { throw new OpenClawAgentConfigurationValidationException( "patch", "Config patch must be a JSON object."); } if (request.Note?.Length is > 1000) { throw new OpenClawAgentConfigurationValidationException( "note", "Note must contain at most 1000 characters."); } if (request.RestartDelayMs is < 0 or > 300_000) { throw new OpenClawAgentConfigurationValidationException( "restartDelayMs", "Restart delay must be between 0 and 300000 milliseconds."); } var secretPath = FindLiteralSecretPath(request.Patch, string.Empty); if (secretPath is not null) { throw new OpenClawAgentConfigurationValidationException( "patch", $"Literal secret values are not accepted by Nexus ({secretPath}); use an OpenClaw SecretRef or environment reference."); } } private static string? FindLiteralSecretPath(JsonNode? node, string path) { if (node is JsonObject obj) { foreach (var property in obj) { var childPath = string.IsNullOrWhiteSpace(path) ? property.Key : $"{path}.{property.Key}"; if (IsSensitiveConfigKey(property.Key) && property.Value is JsonValue value && value.TryGetValue(out var secretValue) && !IsSafeSecretReference(secretValue)) { return childPath; } var nested = FindLiteralSecretPath(property.Value, childPath); if (nested is not null) return nested; } } else if (node is JsonArray array) { for (var index = 0; index < array.Count; index++) { var nested = FindLiteralSecretPath(array[index], $"{path}[{index}]"); if (nested is not null) return nested; } } return null; } private static bool IsSensitiveConfigKey(string key) { var normalized = key.ToLowerInvariant(); if (normalized is "inputtokens" or "outputtokens" or "totaltokens" or "contexttokens" or "maxtokens") return false; return SensitiveConfigKeyFragments.Any(fragment => normalized.Contains(fragment, StringComparison.Ordinal)); } private static bool IsSafeSecretReference(string value) { var trimmed = value.Trim(); return trimmed == "__OPENCLAW_REDACTED__" || (trimmed.StartsWith("${", StringComparison.Ordinal) && trimmed.EndsWith('}') && trimmed.Length > 3); } private static IReadOnlyList NormalizeReplacePaths(IReadOnlyList? replacePaths) { if (replacePaths is null) return []; if (replacePaths.Count > 256) { throw new OpenClawAgentConfigurationValidationException( "replacePaths", "replacePaths must contain at most 256 entries."); } return replacePaths .Select(path => NormalizeConfigPath(path, "replacePaths")) .Distinct(StringComparer.Ordinal) .ToArray(); } private async Task EnsureWriteAvailableAsync( string method, CancellationToken cancellationToken) { var decision = await writeGate.EvaluateAsync( method, "operator.admin", cancellationToken); if (!decision.Allowed) { throw new OpenClawAgentConfigurationUnavailableException( decision.State, method, "operator.admin", decision.Recovery is null ? decision.Message : $"{decision.Message} {decision.Recovery}"); } } private static string NormalizeConfigPath(string? path, string field) { var value = path?.Trim(); if (string.IsNullOrWhiteSpace(value) || value.Length > MaxConfigPathLength || value.Any(character => !(char.IsAsciiLetterOrDigit(character) || character is '_' or '.' or '/' or '[' or ']' or '-' or '*'))) { throw new OpenClawAgentConfigurationValidationException( field, $"{field} must be a valid OpenClaw config path with at most {MaxConfigPathLength} characters."); } return value; } private static OpenClawAgentConfigurationConflictException BuildIdempotencyConflict( OpenClawOperationClaim claim) { var code = claim.Disposition switch { OpenClawOperationClaimDisposition.Conflict => "idempotency_conflict", OpenClawOperationClaimDisposition.InDoubt => "idempotency_in_doubt", _ => "idempotency_replayed_failure" }; return new OpenClawAgentConfigurationConflictException( code, claim.PreviousMessage ?? "Der Idempotency-Key kann nicht erneut verwendet werden."); } private static OpenClawAgentFileWriteDto BuildAgentFileWriteResult( OpenClawAgentFileDto file, OpenClawInvocationContext invocationContext, string state, string message) => new( true, state, message, file, true, invocationContext.IdempotencyKey, invocationContext.CorrelationId, DateTimeOffset.UtcNow, OperationResultFactory.FromInvocation( invocationContext, state, new EntityRefDto( "agent-file", $"{file.AgentId}/{file.Name}", file.Name), affectedRefs: [ new EntityRefDto("agent", file.AgentId) ])); private static OpenClawConfigPatchDto BuildConfigPatchResult( OpenClawConfigSnapshotDto snapshot, JsonNode? restart, OpenClawInvocationContext invocationContext, string state, string message, bool verified = true) => new( verified, state, message, snapshot, restart, true, invocationContext.IdempotencyKey, invocationContext.CorrelationId, DateTimeOffset.UtcNow, OperationResultFactory.FromInvocation( invocationContext, state, new EntityRefDto( "config", "primary", "OpenClaw-Konfiguration"))); private async Task CompleteAuditAsync( OpenClawInvocationContext invocationContext, OpenClawOperationDescriptor descriptor, bool ok, string state, string message, string? errorCode, CancellationToken cancellationToken) => await auditStore.CompleteAsync( invocationContext, descriptor, ok, state, message, errorCode, cancellationToken); private async Task TryCompleteFailedAuditAsync( OpenClawInvocationContext invocationContext, OpenClawOperationDescriptor descriptor, string state, string message) { try { await auditStore.CompleteAsync( invocationContext, descriptor, ok: false, state, message, errorCode: state, CancellationToken.None); } catch (Exception exception) { logger.LogWarning( "OpenClaw mutation audit completion failed ({ExceptionType})", exception.GetType().Name); } } private static JsonNode? SanitizePayload(JsonNode? value) { var redacted = OpenClawPayloadSanitizer.Redact(value); if (redacted is not null) RedactAbsoluteHostPaths(redacted); return redacted; } private static void RedactAbsoluteHostPaths(JsonNode node) { if (node is JsonObject obj) { foreach (var property in obj.ToList()) { if (property.Value is JsonValue value && value.TryGetValue(out var stringValue) && LooksLikeAbsoluteHostPath(stringValue)) { obj[property.Key] = "[host-path-redacted]"; } else if (property.Value is not null) { RedactAbsoluteHostPaths(property.Value); } } } else if (node is JsonArray array) { for (var index = 0; index < array.Count; index++) { var item = array[index]; if (item is JsonValue value && value.TryGetValue(out var stringValue) && LooksLikeAbsoluteHostPath(stringValue)) { array[index] = "[host-path-redacted]"; } else if (item is not null) { RedactAbsoluteHostPaths(item); } } } } private static bool LooksLikeAbsoluteHostPath(string value) { var trimmed = value.Trim(); return trimmed.StartsWith("/", StringComparison.Ordinal) || trimmed.StartsWith(@"\\", StringComparison.Ordinal) || (trimmed.Length >= 3 && char.IsAsciiLetter(trimmed[0]) && trimmed[1] == ':' && trimmed[2] is '\\' or '/'); } private static string HashContent(string content) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content))); private static DateTimeOffset? FromUnixMilliseconds(long? value) { if (value is null) return null; try { return DateTimeOffset.FromUnixTimeMilliseconds(value.Value); } catch (ArgumentOutOfRangeException) { return null; } } private static IEnumerable ReadArray(JsonNode? node, string property) => node?[property] is JsonArray array ? array : []; private static string? ReadString(JsonNode? node, string property) => node?[property] is JsonValue value && value.TryGetValue(out var result) ? result : null; private static bool? ReadBool(JsonNode? node, string property) => node?[property] is JsonValue value && value.TryGetValue(out var result) ? result : null; private static int? ReadInt(JsonNode? node, string property) => node?[property] is JsonValue value && value.TryGetValue(out var result) ? result : null; private static long? ReadLong(JsonNode? node, string property) { if (node?[property] is not JsonValue value) return null; if (value.TryGetValue(out var longResult)) return longResult; return value.TryGetValue(out var intResult) ? intResult : null; } }