feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user