1161 lines
42 KiB
C#
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;
|
|
}
|
|
}
|