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

1161 lines
42 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class OpenClawAgentConfigurationService(
IGatewayConnector connector,
IOpenClawOperationAuditStore auditStore,
IOpenClawWriteGate writeGate,
ILogger<OpenClawAgentConfigurationService> 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<string, string> 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<OpenClawAgentFileCollectionDto> 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<OpenClawAgentFileDto> GetAgentFileAsync(
string agentId,
string fileName,
CancellationToken cancellationToken = default)
{
EnsureAvailable("agents.files.get", "operator.read");
return GetAgentFileCoreAsync(
NormalizeAgentId(agentId),
NormalizeStandardFileName(fileName),
cancellationToken);
}
public async Task<OpenClawAgentFileWriteDto> 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<OpenClawWorkspaceCollectionDto> 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<OpenClawWorkspaceFileDto> 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<OpenClawConfigSchemaLookupDto> 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<OpenClawConfigSnapshotDto> GetConfigAsync(
CancellationToken cancellationToken = default)
{
EnsureAvailable("config.get", "operator.read");
return GetConfigCoreAsync(cancellationToken);
}
public async Task<OpenClawConfigPatchDto> 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<OpenClawAgentFileDto> 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<OpenClawConfigSnapshotDto> 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<string>(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<string> NormalizeReplacePaths(IReadOnlyList<string>? 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<string>(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<string>(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<JsonNode?> 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<string>(out var result)
? result
: null;
private static bool? ReadBool(JsonNode? node, string property)
=> node?[property] is JsonValue value && value.TryGetValue<bool>(out var result)
? result
: null;
private static int? ReadInt(JsonNode? node, string property)
=> node?[property] is JsonValue value && value.TryGetValue<int>(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<long>(out var longResult))
return longResult;
return value.TryGetValue<int>(out var intResult) ? intResult : null;
}
}