Files
nexus/backend-tests/OpenClawAgentConfigurationServiceTests.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

503 lines
18 KiB
C#

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<string>());
}
[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<OpenClawAgentConfigurationConflictException>(
() => 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<string>());
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<OpenClawAgentConfigurationValidationException>(
() => 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<string>());
Assert.Equal(
"[host-path-redacted]",
result.Config?["agents"]?["defaults"]?["workspace"]?.GetValue<string>());
Assert.Equal("visible", result.Config?["safeValue"]?.GetValue<string>());
}
[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<string>());
Assert.Contains(
"\"enabled\":true",
parameters?["raw"]?.GetValue<string>());
Assert.Equal(
"channels.telegram",
parameters?["replacePaths"]?[0]?.GetValue<string>());
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<OpenClawAgentConfigurationValidationException>(
() => 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<AuthorizeAttribute>());
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<ObjectResult>(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<OpenClawAgentConfigurationService>.Instance);
private static AgentConfigurationStubConnector Connected(
IEnumerable<string> methods,
IEnumerable<string> 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<string> AdvertisedMethods { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> AdvertisedEvents { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public IReadOnlySet<string> GrantedScopes { get; set; } =
new HashSet<string>(StringComparer.Ordinal);
public DateTimeOffset? LastEventAt { get; set; }
public Func<string, JsonNode?, JsonNode?>? Handler { get; set; }
public List<AgentConfigurationInvocation> Invocations { get; } = [];
public bool Supports(string method) => AdvertisedMethods.Contains(method);
public Task<JsonNode?> 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<GatewayEventEnvelope> 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<OpenClawOperationClaim> 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;
}
}