using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; using Nexus.Api.Controllers; using Nexus.Api.Models; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public sealed class OpenClawAgentConfigurationServiceTests { [Fact] public async Task AgentFileRead_UsesGatewayContentAndDoesNotExposeHostPaths() { var gateway = Connected( ["agents.files.get"], ["operator.read"]); gateway.Handler = (_, _) => JsonNode.Parse( """ { "agentId": "product-owner", "workspace": "/home/node/.openclaw/workspace-po", "file": { "name": "AGENTS.md", "path": "/home/node/.openclaw/workspace-po/AGENTS.md", "missing": false, "size": 18, "updatedAtMs": 1785000000000, "content": "# Standing orders" } } """); var service = CreateService(gateway); var result = await service.GetAgentFileAsync("product-owner", "agents.md"); Assert.Equal("product-owner", result.AgentId); Assert.Equal("AGENTS.md", result.Name); Assert.Equal("# Standing orders", result.Content); Assert.Equal(Hash("# Standing orders"), result.ContentHash); Assert.DoesNotContain( result.GetType().GetProperties(), property => property.Name is "Path" or "Workspace"); var invocation = Assert.Single(gateway.Invocations); Assert.Equal("agents.files.get", invocation.Method); Assert.Equal("AGENTS.md", invocation.Parameters?["name"]?.GetValue()); } [Fact] public async Task AgentFileList_OnlyReturnsSupportedFiles() { var gateway = Connected( ["agents.files.list"], ["operator.read"]); gateway.Handler = (_, _) => JsonNode.Parse( """ { "workspace": "/home/node/.openclaw/workspace", "files": [ { "name": "SOUL.md", "path": "/home/node/.openclaw/workspace/SOUL.md", "missing": false }, { "name": "MEMORY.md", "path": "/home/node/.openclaw/workspace/MEMORY.md", "missing": true }, { "name": "DREAMS.md", "path": "/home/node/.openclaw/workspace/DREAMS.md", "missing": false } ] } """); var service = CreateService(gateway); var result = await service.GetAgentFilesAsync("main"); Assert.Equal(["SOUL.md", "MEMORY.md"], result.Files.Select(file => file.Name)); Assert.Equal( OpenClawAgentConfigurationService.MissingContentHash, result.Files.Single(file => file.Name == "MEMORY.md").ContentHash); } [Fact] public async Task AgentFileWrite_RejectsStaleExpectedHashBeforeMutation() { var gateway = Connected( ["agents.files.get", "agents.files.set"], ["operator.admin"]); gateway.Handler = (method, _) => method == "agents.files.get" ? AgentFile("SOUL.md", "current") : JsonNode.Parse("""{ "ok": true }"""); var audit = new FakeOperationAuditStore(); var service = CreateService(gateway, audit); var exception = await Assert.ThrowsAsync( () => service.SetAgentFileAsync( "iris", "SOUL.md", new UpdateOpenClawAgentFileRequest("next", new string('0', 64)), Invocation())); Assert.Equal("content_hash_mismatch", exception.Code); Assert.Equal(Hash("current"), exception.CurrentHash); Assert.DoesNotContain(gateway.Invocations, item => item.Method == "agents.files.set"); var completion = Assert.Single(audit.Completions); Assert.False(completion.Ok); Assert.Equal("conflict", completion.State); } [Fact] public async Task AgentFileWrite_IsReadBackVerifiedAndCarriesInvocationMetadata() { var gateway = Connected( ["agents.files.get", "agents.files.set"], ["operator.admin"]); var reads = 0; gateway.Handler = (method, parameters) => { if (method == "agents.files.set") { Assert.Null(parameters?["idempotencyKey"]); Assert.Equal("next", parameters?["content"]?.GetValue()); return JsonNode.Parse("""{ "ok": true }"""); } reads++; return AgentFile("SOUL.md", reads == 1 ? "current" : "next"); }; var audit = new FakeOperationAuditStore(); var service = CreateService(gateway, audit); var invocation = Invocation(); var result = await service.SetAgentFileAsync( "iris", "SOUL.md", new UpdateOpenClawAgentFileRequest("next", Hash("current")), invocation); Assert.True(result.Ok); Assert.True(result.Verified); Assert.Equal("completed", result.State); Assert.Equal(invocation.IdempotencyKey, result.IdempotencyKey); Assert.Equal(Hash("next"), result.File.ContentHash); Assert.Equal( ["agents.files.get", "agents.files.set", "agents.files.get"], gateway.Invocations.Select(item => item.Method)); Assert.Equal(invocation, gateway.Invocations[1].Context); Assert.True(Assert.Single(audit.Completions).Ok); } [Theory] [InlineData("../secrets/.env")] [InlineData("/home/node/.openclaw/openclaw.json")] [InlineData("memory/../../openclaw.json")] [InlineData("credentials.json")] public async Task WorkspaceRead_RejectsUnsafePathsBeforeGateway(string path) { var gateway = Connected( ["agents.workspace.get"], ["operator.read"]); var service = CreateService(gateway); await Assert.ThrowsAsync( () => service.GetWorkspaceFileAsync("main", path)); Assert.Empty(gateway.Invocations); } [Fact] public async Task WorkspaceList_FiltersSensitiveAndAbsoluteEntries() { var gateway = Connected( ["agents.workspace.list"], ["operator.read"]); gateway.Handler = (_, _) => JsonNode.Parse( """ { "agentId": "main", "path": "", "entries": [ { "path": "DREAMS.md", "name": "DREAMS.md", "kind": "file", "size": 12 }, { "path": ".env", "name": ".env", "kind": "file", "size": 20 }, { "path": "/home/node/.openclaw/openclaw.json", "name": "openclaw.json", "kind": "file" } ], "totalEntries": 3, "offset": 0 } """); var service = CreateService(gateway); var result = await service.GetWorkspaceAsync("main", null, 0, 250); var entry = Assert.Single(result.Entries); Assert.Equal("DREAMS.md", entry.Path); } [Fact] public async Task ConfigRead_RedactsSecretsAndAbsoluteHostPaths() { var gateway = Connected( ["config.get"], ["operator.read"]); gateway.Handler = (_, _) => ConfigSnapshot( new string('a', 64), new JsonObject { ["gateway"] = new JsonObject { ["auth"] = new JsonObject { ["token"] = "super-secret-token" } }, ["agents"] = new JsonObject { ["defaults"] = new JsonObject { ["workspace"] = "/home/node/.openclaw/workspace" } }, ["safeValue"] = "visible" }); var service = CreateService(gateway); var result = await service.GetConfigAsync(); Assert.Equal( "[redacted]", result.Config?["gateway"]?["auth"]?["token"]?.GetValue()); Assert.Equal( "[host-path-redacted]", result.Config?["agents"]?["defaults"]?["workspace"]?.GetValue()); Assert.Equal("visible", result.Config?["safeValue"]?.GetValue()); } [Fact] public async Task ConfigPatch_UsesBaseHashReplacePathsAndValidReadBack() { var beforeHash = new string('a', 64); var afterHash = new string('b', 64); var gateway = Connected( ["config.get", "config.patch"], ["operator.admin"]); var reads = 0; gateway.Handler = (method, parameters) => { if (method == "config.get") { reads++; return ConfigSnapshot( reads == 1 ? beforeHash : afterHash, new JsonObject { ["channels"] = new JsonObject { ["telegram"] = new JsonObject { ["enabled"] = reads > 1 } } }); } Assert.Equal(beforeHash, parameters?["baseHash"]?.GetValue()); Assert.Contains( "\"enabled\":true", parameters?["raw"]?.GetValue()); Assert.Equal( "channels.telegram", parameters?["replacePaths"]?[0]?.GetValue()); Assert.Null(parameters?["idempotencyKey"]); return JsonNode.Parse( """{ "ok": true, "restart": { "required": false } }"""); }; var audit = new FakeOperationAuditStore(); var service = CreateService(gateway, audit); var invocation = Invocation(); var result = await service.PatchConfigAsync( new PatchOpenClawConfigRequest( new JsonObject { ["channels"] = new JsonObject { ["telegram"] = new JsonObject { ["enabled"] = true } } }, beforeHash, ["channels.telegram"]), invocation); Assert.True(result.Ok); Assert.True(result.Verified); Assert.Equal(afterHash, result.Snapshot.Hash); Assert.Equal( ["config.get", "config.patch", "config.get"], gateway.Invocations.Select(item => item.Method)); Assert.True(Assert.Single(audit.Completions).Ok); } [Fact] public async Task ConfigPatch_RejectsLiteralSecretsBeforeGatewayOrAudit() { var gateway = Connected( ["config.patch"], ["operator.admin"]); var audit = new FakeOperationAuditStore(); var service = CreateService(gateway, audit); var exception = await Assert.ThrowsAsync( () => service.PatchConfigAsync( new PatchOpenClawConfigRequest( new JsonObject { ["models"] = new JsonObject { ["providers"] = new JsonObject { ["openai"] = new JsonObject { ["apiKey"] = "sk-test-secret" } } } }, new string('a', 64)), Invocation())); Assert.Equal("patch", exception.Field); Assert.Empty(gateway.Invocations); Assert.Empty(audit.Claims); } [Fact] public async Task Controller_MapsContentConflictTo409AndEchoesIdempotencyKey() { var authorize = Assert.Single( typeof(OpenClawAgentConfigurationController) .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) .Cast()); Assert.Equal("owner", authorize.Roles); var gateway = Connected( ["agents.files.get", "agents.files.set"], ["operator.admin"]); gateway.Handler = (_, _) => AgentFile("AGENTS.md", "current"); var service = CreateService(gateway); var controller = new OpenClawAgentConfigurationController(service); var httpContext = new DefaultHttpContext(); httpContext.Request.Headers["Idempotency-Key"] = "agent-file-test-1"; httpContext.User = new ClaimsPrincipal(new ClaimsIdentity( [ new Claim("sub", "bao"), new Claim(ClaimTypes.Role, "owner") ], authenticationType: "test")); controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; var response = await controller.SetAgentFile( "main", "AGENTS.md", new UpdateOpenClawAgentFileRequest("next", new string('0', 64)), CancellationToken.None); var result = Assert.IsType(response.Result); Assert.Equal(StatusCodes.Status409Conflict, result.StatusCode); Assert.Equal( "agent-file-test-1", httpContext.Response.Headers["Idempotency-Key"].ToString()); } private static OpenClawAgentConfigurationService CreateService( AgentConfigurationStubConnector gateway, FakeOperationAuditStore? audit = null) => new( gateway, audit ?? new FakeOperationAuditStore(), new StubOpenClawWriteGate(), NullLogger.Instance); private static AgentConfigurationStubConnector Connected( IEnumerable methods, IEnumerable scopes) => new() { ConnectionState = GatewayConnectionState.Connected, AdvertisedMethods = methods.ToHashSet(StringComparer.Ordinal), GrantedScopes = scopes.ToHashSet(StringComparer.Ordinal) }; private static OpenClawInvocationContext Invocation() => OpenClawInvocationContext.Create( actor: "bao", idempotencyKey: $"test-{Guid.NewGuid():N}", correlationId: $"corr-{Guid.NewGuid():N}"); private static JsonNode? AgentFile(string name, string content) => JsonNode.Parse( $$""" { "file": { "name": "{{name}}", "path": "/home/node/.openclaw/workspace/{{name}}", "missing": false, "size": {{Encoding.UTF8.GetByteCount(content)}}, "updatedAtMs": 1785000000000, "content": {{System.Text.Json.JsonSerializer.Serialize(content)}} } } """); private static JsonNode ConfigSnapshot(string hash, JsonNode config) => new JsonObject { ["exists"] = true, ["valid"] = true, ["hash"] = hash, ["config"] = config, ["issues"] = new JsonArray(), ["warnings"] = new JsonArray() }; private static string Hash(string content) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content))); } internal sealed record AgentConfigurationInvocation( string Method, JsonNode? Parameters, OpenClawInvocationContext? Context); internal sealed class AgentConfigurationStubConnector : IGatewayConnector { public GatewayConnectionState ConnectionState { get; set; } = GatewayConnectionState.Initializing; public string? GatewayVersion { get; set; } = "2026.7.1"; public string? RequiredVersion { get; set; } public DateTimeOffset? LastConnectedAt { get; set; } public int ReconnectAttempts { get; set; } public string? StatusMessage { get; set; } public string? DeviceId { get; set; } public bool DeviceTokenConfigured { get; set; } public bool PairingRequired { get; set; } public string? PairingRequestId { get; set; } public int? ProtocolVersion { get; set; } = 4; public IReadOnlySet AdvertisedMethods { get; set; } = new HashSet(StringComparer.Ordinal); public IReadOnlySet AdvertisedEvents { get; set; } = new HashSet(StringComparer.Ordinal); public IReadOnlySet GrantedScopes { get; set; } = new HashSet(StringComparer.Ordinal); public DateTimeOffset? LastEventAt { get; set; } public Func? Handler { get; set; } public List Invocations { get; } = []; public bool Supports(string method) => AdvertisedMethods.Contains(method); public Task InvokeAsync( string method, object? parameters = null, TimeSpan? timeout = null, CancellationToken cancellationToken = default, OpenClawInvocationContext? invocationContext = null) { var node = parameters switch { null => null, JsonNode jsonNode => jsonNode.DeepClone(), _ => System.Text.Json.JsonSerializer.SerializeToNode(parameters) }; Invocations.Add(new AgentConfigurationInvocation(method, node, invocationContext)); return Task.FromResult(Handler?.Invoke(method, node)); } public IReadOnlyList GetRecentEvents(int limit = 100) => []; } internal sealed class FakeOperationAuditStore : IOpenClawOperationAuditStore { public string AuditPath => "test-only"; public OpenClawOperationClaim NextClaim { get; set; } = new(OpenClawOperationClaimDisposition.Started); public List<(OpenClawInvocationContext Context, OpenClawOperationDescriptor Descriptor)> Claims { get; } = []; public List<(bool Ok, string State, string Message, string? ErrorCode)> Completions { get; } = []; public Task ClaimAsync( OpenClawInvocationContext context, OpenClawOperationDescriptor operation, CancellationToken cancellationToken = default) { Claims.Add((context, operation)); return Task.FromResult(NextClaim); } public Task CompleteAsync( OpenClawInvocationContext context, OpenClawOperationDescriptor operation, bool ok, string state, string message, string? errorCode = null, CancellationToken cancellationToken = default) { Completions.Add((ok, state, message, errorCode)); return Task.CompletedTask; } }