503 lines
18 KiB
C#
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;
|
|
}
|
|
}
|