Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a55951f315 | |||
| 77b9587fa6 | |||
| 17dc84082c | |||
| 8ad8c956eb | |||
| dbda764190 |
@@ -3,18 +3,13 @@ set -eu
|
|||||||
|
|
||||||
DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/nexus}"
|
DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/nexus}"
|
||||||
ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}"
|
ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}"
|
||||||
COMPOSE_SCRIPT_TEMPLATE="${COMPOSE_SCRIPT:-/tmp/nexus-compose-deploy.sh}"
|
|
||||||
ENV_TMPFILE=""
|
ENV_TMPFILE=""
|
||||||
COMPOSE_SCRIPT=""
|
|
||||||
BASE_URL="${BASE_URL:-https://nexus.noveria.net}"
|
BASE_URL="${BASE_URL:-https://nexus.noveria.net}"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [ -n "$ENV_TMPFILE" ] && [ -f "$ENV_TMPFILE" ]; then
|
if [ -n "$ENV_TMPFILE" ] && [ -f "$ENV_TMPFILE" ]; then
|
||||||
shred -u "$ENV_TMPFILE" 2>/dev/null || rm -f "$ENV_TMPFILE"
|
shred -u "$ENV_TMPFILE" 2>/dev/null || rm -f "$ENV_TMPFILE"
|
||||||
fi
|
fi
|
||||||
if [ -n "$COMPOSE_SCRIPT" ]; then
|
|
||||||
rm -f "$COMPOSE_SCRIPT"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
@@ -39,8 +34,7 @@ secure_tmpfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ENV_TMPFILE="$(secure_tmpfile "$ENV_TMPFILE_TEMPLATE")"
|
ENV_TMPFILE="$(secure_tmpfile "$ENV_TMPFILE_TEMPLATE")"
|
||||||
COMPOSE_SCRIPT="$(secure_tmpfile "$COMPOSE_SCRIPT_TEMPLATE")"
|
chmod 600 "$ENV_TMPFILE"
|
||||||
chmod 600 "$ENV_TMPFILE" "$COMPOSE_SCRIPT"
|
|
||||||
|
|
||||||
if [ ! -f VERSION ]; then
|
if [ ! -f VERSION ]; then
|
||||||
echo "VERSION file not found" >&2
|
echo "VERSION file not found" >&2
|
||||||
@@ -111,25 +105,51 @@ git archive --format=tar HEAD | docker run --rm -i \
|
|||||||
chown -R "$dest_owner" /dest
|
chown -R "$dest_owner" /dest
|
||||||
'
|
'
|
||||||
|
|
||||||
echo "Building and starting Docker compose stack"
|
# ── Sanitized agents config for Nexus (no secrets) ──
|
||||||
cat > "$COMPOSE_SCRIPT" <<'EOF_DEPLOY'
|
echo "Generating sanitized agents config for Nexus (no secrets from openclaw.json)"
|
||||||
#!/bin/sh
|
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
|
||||||
set -eu
|
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
|
||||||
cat > /tmp/nexus-deploy-env
|
OPENCLAW_CONFIG_DIR="/home/projekte_bao/openclaw/data/openclaw"
|
||||||
docker compose --env-file /tmp/nexus-deploy-env build
|
|
||||||
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate --remove-orphans --wait
|
|
||||||
docker compose --env-file /tmp/nexus-deploy-env ps
|
|
||||||
rm -f /tmp/nexus-deploy-env
|
|
||||||
EOF_DEPLOY
|
|
||||||
|
|
||||||
|
# Extract only "agents" key from openclaw.json using jq in an alpine container.
|
||||||
|
# This ensures NO secrets (gateway, channels, auth, etc.) leak into the sanitized file.
|
||||||
|
if docker run --rm \
|
||||||
|
-v "$OPENCLAW_CONFIG:/input/openclaw.json:ro" \
|
||||||
|
-v "$OPENCLAW_CONFIG_DIR:/output" \
|
||||||
|
alpine:3.20 \
|
||||||
|
sh -c '
|
||||||
|
if ! apk add --no-cache jq >/dev/null 2>&1; then
|
||||||
|
echo "WARNING: jq not available, agents-sanitized.json NOT regenerated" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f /input/openclaw.json ]; then
|
||||||
|
echo "WARNING: openclaw.json not found — agents-sanitized.json NOT regenerated" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
jq "{agents: .agents}" /input/openclaw.json > /output/agents-sanitized.json
|
||||||
|
count=$(jq ".agents.list | length" /output/agents-sanitized.json 2>/dev/null || echo 0)
|
||||||
|
echo "Sanitized agents config written ($count agents)"
|
||||||
|
' 2>&1; then
|
||||||
|
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
|
||||||
|
else
|
||||||
|
echo "WARNING: Failed to generate agents-sanitized.json — Nexus will use fallback agent IDs" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building and starting Docker compose stack"
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$DEPLOY_PATH:/workspace/nexus" \
|
-v "$DEPLOY_PATH:/workspace/nexus" \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-v "$COMPOSE_SCRIPT:/deploy.sh:ro" \
|
|
||||||
-w /workspace/nexus \
|
-w /workspace/nexus \
|
||||||
-i \
|
-i \
|
||||||
docker:cli \
|
docker:cli \
|
||||||
sh /deploy.sh < "$ENV_TMPFILE"
|
sh -c 'set -eu
|
||||||
|
umask 077
|
||||||
|
cat > /tmp/nexus-deploy-env
|
||||||
|
trap '\''rm -f /tmp/nexus-deploy-env'\'' EXIT INT TERM
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env build
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate --remove-orphans --wait
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env ps
|
||||||
|
' < "$ENV_TMPFILE"
|
||||||
|
|
||||||
echo "Verifying image provenance"
|
echo "Verifying image provenance"
|
||||||
for container in nexus-api-1 nexus-web-1; do
|
for container in nexus-api-1 nexus-web-1; do
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the MCP server configuration is correct:
|
||||||
|
/// all tools are registered, the streamable-http transport is configured
|
||||||
|
/// via the service extensions, and MapMcp is called in Program.cs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class McpServerConfigurationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void McpServer_CanBeRegistered_WithoutError()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddOptions();
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
// Simulate what AddNexusApplicationServices does
|
||||||
|
services.AddMcpServer()
|
||||||
|
.WithHttpTransport(options => options.Stateless = true)
|
||||||
|
.WithTools<NexusMcpTools>();
|
||||||
|
|
||||||
|
// Build the container — this should not throw
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
// Verify the ToolType is properly decorated
|
||||||
|
var attr = typeof(NexusMcpTools).GetCustomAttributes(
|
||||||
|
typeof(McpServerToolTypeAttribute), inherit: false);
|
||||||
|
Assert.Single(attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void McpServer_ToolTypeAttribute_IsPresent()
|
||||||
|
{
|
||||||
|
var attr = typeof(NexusMcpTools).GetCustomAttributes(
|
||||||
|
typeof(McpServerToolTypeAttribute), inherit: false);
|
||||||
|
Assert.Single(attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void McpEndpoint_MapMcp_IsCalledInProgram()
|
||||||
|
{
|
||||||
|
// Source path relative to the test output directory
|
||||||
|
var programPath = ResolveSourcePath("backend", "Program.cs");
|
||||||
|
Assert.True(File.Exists(programPath), $"Program.cs not found at {programPath}");
|
||||||
|
var source = File.ReadAllText(programPath);
|
||||||
|
Assert.Contains("MapMcp", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void McpServerRegistration_IsCalledInServiceExtensions()
|
||||||
|
{
|
||||||
|
var extPath = ResolveSourcePath("backend", "Extensions", "ServiceCollectionExtensions.cs");
|
||||||
|
Assert.True(File.Exists(extPath), $"ServiceCollectionExtensions.cs not found at {extPath}");
|
||||||
|
var source = File.ReadAllText(extPath);
|
||||||
|
Assert.Contains("AddMcpServer", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("WithHttpTransport", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Stateless", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("WithTools<NexusMcpTools>", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NuGetPackage_ModelContextProtocol_AspNetCore_IsReferenced()
|
||||||
|
{
|
||||||
|
var csprojPath = ResolveSourcePath("backend", "Nexus.Api.csproj");
|
||||||
|
Assert.True(File.Exists(csprojPath), $"Nexus.Api.csproj not found at {csprojPath}");
|
||||||
|
var source = File.ReadAllText(csprojPath);
|
||||||
|
Assert.Contains("ModelContextProtocol.AspNetCore", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveSourcePath(params string[] segments)
|
||||||
|
{
|
||||||
|
// Navigate from test output directory to the repo root
|
||||||
|
// Test DLL is at: backend-tests/bin/Debug/net10.0/Nexus.Api.Tests.dll
|
||||||
|
// We go up 4 levels (net10.0 → Debug → bin → backend-tests) to reach repo root
|
||||||
|
var baseDir = AppContext.BaseDirectory;
|
||||||
|
var repoRoot = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", ".."));
|
||||||
|
return Path.Combine(new[] { repoRoot }.Concat(segments).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class McpToolsTests
|
||||||
|
{
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Enum Validation
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NexusMcpTaskState_HasExactlyFiveValidStates()
|
||||||
|
{
|
||||||
|
var values = Enum.GetValues<NexusMcpTaskState>();
|
||||||
|
Assert.Equal(5, values.Length);
|
||||||
|
|
||||||
|
var names = Enum.GetNames<NexusMcpTaskState>();
|
||||||
|
Assert.Contains("Backlog", names);
|
||||||
|
Assert.Contains("InProgress", names);
|
||||||
|
Assert.Contains("Blocked", names);
|
||||||
|
Assert.Contains("Done", names);
|
||||||
|
Assert.Contains("Review", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NexusMcpTaskState.Backlog, "Backlog")]
|
||||||
|
[InlineData(NexusMcpTaskState.InProgress, "In progress")]
|
||||||
|
[InlineData(NexusMcpTaskState.Blocked, "Blocked")]
|
||||||
|
[InlineData(NexusMcpTaskState.Done, "Done")]
|
||||||
|
[InlineData(NexusMcpTaskState.Review, "Review")]
|
||||||
|
public void NexusMcpTaskState_MapsToCorrectStateString(NexusMcpTaskState mcpState, string expectedBridgeState)
|
||||||
|
{
|
||||||
|
// Verify the TaskStateHelper roundtrip works
|
||||||
|
string stateString = mcpState switch
|
||||||
|
{
|
||||||
|
NexusMcpTaskState.Backlog => TaskStateHelper.ToStateString(TaskState.Backlog),
|
||||||
|
NexusMcpTaskState.InProgress => TaskStateHelper.ToStateString(TaskState.InProgress),
|
||||||
|
NexusMcpTaskState.Blocked => TaskStateHelper.ToStateString(TaskState.Blocked),
|
||||||
|
NexusMcpTaskState.Done => TaskStateHelper.ToStateString(TaskState.Done),
|
||||||
|
NexusMcpTaskState.Review => TaskStateHelper.ToStateString(TaskState.Review),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(mcpState))
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(expectedBridgeState, stateString);
|
||||||
|
Assert.True(TaskStateHelper.IsValidState(stateString));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NexusMcpTaskState_EnumValuesMatchTaskStateEnum()
|
||||||
|
{
|
||||||
|
// The MCP state enum must cover exactly the canonical task states
|
||||||
|
foreach (var mcpState in Enum.GetValues<NexusMcpTaskState>())
|
||||||
|
{
|
||||||
|
var taskState = mcpState switch
|
||||||
|
{
|
||||||
|
NexusMcpTaskState.Backlog => TaskState.Backlog,
|
||||||
|
NexusMcpTaskState.InProgress => TaskState.InProgress,
|
||||||
|
NexusMcpTaskState.Blocked => TaskState.Blocked,
|
||||||
|
NexusMcpTaskState.Done => TaskState.Done,
|
||||||
|
NexusMcpTaskState.Review => TaskState.Review,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(mcpState))
|
||||||
|
};
|
||||||
|
Assert.True(Enum.IsDefined(taskState));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Tool Registration
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllRequiredTools_AreRegistered()
|
||||||
|
{
|
||||||
|
var toolMethods = typeof(NexusMcpTools)
|
||||||
|
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||||
|
.Where(m => m.GetCustomAttribute<McpServerToolAttribute>() is not null)
|
||||||
|
.Select(m => m.GetCustomAttribute<McpServerToolAttribute>()!.Name!)
|
||||||
|
.OrderBy(n => n)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var expected = new[]
|
||||||
|
{
|
||||||
|
"nexus_agent_overview",
|
||||||
|
"nexus_append_activity",
|
||||||
|
"nexus_create_child_task",
|
||||||
|
"nexus_create_task",
|
||||||
|
"nexus_get_activity",
|
||||||
|
"nexus_get_board",
|
||||||
|
"nexus_get_children",
|
||||||
|
"nexus_get_task",
|
||||||
|
"nexus_handoff",
|
||||||
|
"nexus_update_status"
|
||||||
|
}.OrderBy(n => n).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(expected, toolMethods);
|
||||||
|
Assert.Equal(10, toolMethods.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NexusMcpTools_HasMcpServerToolTypeAttribute()
|
||||||
|
{
|
||||||
|
var attr = typeof(NexusMcpTools).GetCustomAttribute<McpServerToolTypeAttribute>();
|
||||||
|
Assert.NotNull(attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllTools_HaveDescriptionAttribute()
|
||||||
|
{
|
||||||
|
var methods = typeof(NexusMcpTools)
|
||||||
|
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||||
|
.Where(m => m.GetCustomAttribute<McpServerToolAttribute>() is not null);
|
||||||
|
|
||||||
|
foreach (var method in methods)
|
||||||
|
{
|
||||||
|
var desc = method.GetCustomAttribute<DescriptionAttribute>();
|
||||||
|
Assert.NotNull(desc);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(desc!.Description),
|
||||||
|
$"Tool {method.Name} is missing a description.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NexusMcpTools_AllMethodsAreAsync()
|
||||||
|
{
|
||||||
|
var methods = typeof(NexusMcpTools)
|
||||||
|
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||||
|
.Where(m => m.GetCustomAttribute<McpServerToolAttribute>() is not null);
|
||||||
|
|
||||||
|
foreach (var method in methods)
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
method.ReturnType.Name.StartsWith("Task") ||
|
||||||
|
method.ReturnType.Name.StartsWith("ValueTask"),
|
||||||
|
$"Tool {method.Name} does not return Task.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Tool Behavior via Fixture (integration-style)
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTask_ReturnsSuccess_ForValidInput()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var result = await fixture.Tools.CreateTask("MCP Test Task", "MCP detail", "High", "iris");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.True(result.Ok);
|
||||||
|
Assert.Equal("nexus_create_task", result.Command);
|
||||||
|
Assert.NotNull(result.Data);
|
||||||
|
Assert.Null(result.Error);
|
||||||
|
Assert.Equal("MCP Test Task", result.Data!.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildTask_ReturnsSuccess_ForValidParent()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var parent = await fixture.Tools.CreateTask("Parent Task", "Parent detail");
|
||||||
|
Assert.True(parent.Ok && parent.Data is not null);
|
||||||
|
|
||||||
|
var child = await fixture.Tools.CreateChildTask(
|
||||||
|
parent.Data!.Id, "Child Task", "Child detail", "Normal", "programmer");
|
||||||
|
|
||||||
|
Assert.True(child.Ok);
|
||||||
|
Assert.Equal("nexus_create_child_task", child.Command);
|
||||||
|
Assert.NotNull(child.Data);
|
||||||
|
Assert.Equal("Child Task", child.Data!.Title);
|
||||||
|
Assert.Equal(parent.Data.Id, child.Data.ParentTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildTask_ReturnsError_ForMissingParent()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var result = await fixture.Tools.CreateChildTask(
|
||||||
|
Guid.NewGuid(), "Orphan Child");
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoard_ReturnsGroupedTasks()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
// Create test tasks
|
||||||
|
await fixture.Tools.CreateTask("Board Task 1", assignedTo: "iris");
|
||||||
|
await fixture.Tools.CreateTask("Board Task 2", assignedTo: "programmer");
|
||||||
|
|
||||||
|
var board = await fixture.Tools.GetBoard();
|
||||||
|
|
||||||
|
Assert.NotNull(board);
|
||||||
|
Assert.NotNull(board.Offen);
|
||||||
|
Assert.NotNull(board.InProgress);
|
||||||
|
Assert.NotNull(board.Review);
|
||||||
|
Assert.NotNull(board.Blocked);
|
||||||
|
Assert.NotNull(board.Done);
|
||||||
|
Assert.True(board.Offen.Count >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTask_ReturnsTask_WhenFound()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var created = await fixture.Tools.CreateTask("GetTask Test");
|
||||||
|
Assert.True(created.Ok && created.Data is not null);
|
||||||
|
|
||||||
|
var fetched = await fixture.Tools.GetTask(created.Data.Id);
|
||||||
|
|
||||||
|
Assert.True(fetched.Ok);
|
||||||
|
Assert.Equal("GetTask Test", fetched.Data!.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTask_ReturnsError_WhenNotFound()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var result = await fixture.Tools.GetTask(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetChildren_ReturnsChildTasks()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var parent = await fixture.Tools.CreateTask("Parent for Children");
|
||||||
|
Assert.True(parent.Ok && parent.Data is not null);
|
||||||
|
|
||||||
|
await fixture.Tools.CreateChildTask(parent.Data.Id, "Child 1");
|
||||||
|
await fixture.Tools.CreateChildTask(parent.Data.Id, "Child 2");
|
||||||
|
|
||||||
|
var children = await fixture.Tools.GetChildren(parent.Data.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(children);
|
||||||
|
Assert.Equal(2, children.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStatus_AdvancesState()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var task = await fixture.Tools.CreateTask("Status Test");
|
||||||
|
Assert.True(task.Ok && task.Data is not null);
|
||||||
|
Assert.Equal("Backlog", task.Data.State);
|
||||||
|
|
||||||
|
var inProgress = await fixture.Tools.UpdateStatus(task.Data.Id, NexusMcpTaskState.InProgress);
|
||||||
|
Assert.True(inProgress.Ok);
|
||||||
|
Assert.Equal("In progress", inProgress.Data!.State);
|
||||||
|
|
||||||
|
var done = await fixture.Tools.UpdateStatus(task.Data.Id, NexusMcpTaskState.Done);
|
||||||
|
Assert.True(done.Ok);
|
||||||
|
Assert.Equal("Done", done.Data!.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStatus_Unauthorized_ForSubAgent()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("programmer");
|
||||||
|
|
||||||
|
var task = await fixture.Tools.CreateTask("SubAgent Status Test");
|
||||||
|
Assert.True(task.Ok && task.Data is not null);
|
||||||
|
|
||||||
|
// The programmer creates the task fine, but cannot change status
|
||||||
|
var result = await fixture.Tools.UpdateStatus(task.Data.Id, NexusMcpTaskState.InProgress);
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.Contains("not authorized", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(NexusMcpTaskState.Backlog)]
|
||||||
|
[InlineData(NexusMcpTaskState.InProgress)]
|
||||||
|
[InlineData(NexusMcpTaskState.Review)]
|
||||||
|
[InlineData(NexusMcpTaskState.Blocked)]
|
||||||
|
[InlineData(NexusMcpTaskState.Done)]
|
||||||
|
public async Task UpdateStatus_AcceptsAllValidStates(NexusMcpTaskState state)
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var task = await fixture.Tools.CreateTask($"StateTest-{state}");
|
||||||
|
Assert.True(task.Ok && task.Data is not null);
|
||||||
|
|
||||||
|
var result = await fixture.Tools.UpdateStatus(task.Data.Id, state);
|
||||||
|
Assert.True(result.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AppendActivity_WritesActivityEntry()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var task = await fixture.Tools.CreateTask("Activity Test");
|
||||||
|
Assert.True(task.Ok && task.Data is not null);
|
||||||
|
|
||||||
|
var result = await fixture.Tools.AppendActivity(task.Data.Id, "Test checkpoint", "checkpoint");
|
||||||
|
Assert.True(result.Ok);
|
||||||
|
Assert.NotNull(result.Data);
|
||||||
|
Assert.Equal("Test checkpoint", result.Data!.Message);
|
||||||
|
|
||||||
|
var activities = await fixture.Tools.GetActivity(task.Data.Id);
|
||||||
|
Assert.NotEmpty(activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handoff_UpdatesExpectedFrom()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var task = await fixture.Tools.CreateTask("Handoff Test");
|
||||||
|
Assert.True(task.Ok && task.Data is not null);
|
||||||
|
|
||||||
|
var result = await fixture.Tools.Handoff(task.Data.Id, "programmer", "Please implement");
|
||||||
|
Assert.True(result.Ok);
|
||||||
|
Assert.Equal("programmer", result.Data!.ExpectedFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAgentOverview_ReturnsGroupedWorkflow()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("iris");
|
||||||
|
|
||||||
|
var overview = await fixture.Tools.GetAgentOverview(staleHours: 2);
|
||||||
|
|
||||||
|
Assert.NotNull(overview);
|
||||||
|
Assert.NotNull(overview.WaitingForBao);
|
||||||
|
Assert.NotNull(overview.WaitingForIris);
|
||||||
|
Assert.NotNull(overview.WaitingForOthers);
|
||||||
|
Assert.NotNull(overview.StaleTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Auth Resolution
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveCaller_AcceptsValidXAgentId()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("programmer");
|
||||||
|
|
||||||
|
// Simply verify a tool call succeeds with a valid agent header
|
||||||
|
var result = await fixture.Tools.CreateTask("Auth Test via header");
|
||||||
|
Assert.True(result.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveCaller_AcceptsJwtClaim()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerUser("iris", "member");
|
||||||
|
|
||||||
|
var result = await fixture.Tools.CreateTask("Auth Test via JWT");
|
||||||
|
Assert.True(result.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveCaller_AcceptsOwnerRole()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerUser("owner", "owner");
|
||||||
|
|
||||||
|
var result = await fixture.Tools.CreateTask("Auth Test via owner JWT");
|
||||||
|
Assert.True(result.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveCaller_AcceptsServiceKey()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerServiceKey("test-service-key");
|
||||||
|
|
||||||
|
var result = await fixture.Tools.CreateTask("Auth Test via service key");
|
||||||
|
Assert.True(result.Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveCaller_RejectsUnknownAgentId()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
fixture.SetCallerAgent("hacker");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||||
|
() => fixture.Tools.CreateTask("Should fail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveCaller_RejectsMissingAuth()
|
||||||
|
{
|
||||||
|
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||||
|
// No auth set = should reject
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||||
|
() => fixture.Tools.CreateTask("Should fail"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Test Fixture
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
internal sealed class McpToolsFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly NexusDbContext _db;
|
||||||
|
|
||||||
|
private McpToolsFixture(
|
||||||
|
NexusDbContext db,
|
||||||
|
NexusMcpTools tools,
|
||||||
|
HttpContextAccessor httpContextAccessor,
|
||||||
|
ITaskBridgeService taskBridgeService,
|
||||||
|
IAgentService agentService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
Tools = tools;
|
||||||
|
HttpContextAccessor = httpContextAccessor;
|
||||||
|
TaskBridgeService = taskBridgeService;
|
||||||
|
AgentService = agentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NexusMcpTools Tools { get; }
|
||||||
|
public HttpContextAccessor HttpContextAccessor { get; }
|
||||||
|
public ITaskBridgeService TaskBridgeService { get; }
|
||||||
|
public IAgentService AgentService { get; }
|
||||||
|
|
||||||
|
public static async Task<McpToolsFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
var db = new NexusDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var configPath = CreateAgentConfigFile();
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AgentConfigPath"] = configPath,
|
||||||
|
["NexusApiKey"] = "test-service-key"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var agentService = new AgentService(configuration, new FakeRuntime());
|
||||||
|
var liveUpdateService = new LiveUpdateService();
|
||||||
|
var activityRepository = new ActivityRepository(db, liveUpdateService);
|
||||||
|
var taskRepository = new TaskRepository(db);
|
||||||
|
var notificationService = new NotificationService(db, liveUpdateService);
|
||||||
|
|
||||||
|
var httpContextAccessor = new HttpContextAccessor();
|
||||||
|
|
||||||
|
var staleTaskRecoveryService = new StaleTaskRecoveryService(
|
||||||
|
taskRepository, activityRepository, liveUpdateService);
|
||||||
|
|
||||||
|
var taskService = new TaskService(
|
||||||
|
taskRepository,
|
||||||
|
activityRepository,
|
||||||
|
notificationService,
|
||||||
|
agentService,
|
||||||
|
httpContextAccessor,
|
||||||
|
liveUpdateService,
|
||||||
|
staleTaskRecoveryService);
|
||||||
|
|
||||||
|
var taskBridgeService = new TaskBridgeService(
|
||||||
|
taskService,
|
||||||
|
agentService,
|
||||||
|
activityRepository,
|
||||||
|
notificationService,
|
||||||
|
liveUpdateService);
|
||||||
|
|
||||||
|
var logger = NullLogger<NexusMcpTools>.Instance;
|
||||||
|
|
||||||
|
var tools = new NexusMcpTools(
|
||||||
|
taskBridgeService,
|
||||||
|
agentService,
|
||||||
|
httpContextAccessor,
|
||||||
|
configuration,
|
||||||
|
logger);
|
||||||
|
|
||||||
|
return new McpToolsFixture(db, tools, httpContextAccessor, taskBridgeService, agentService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _db.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCallerAgent(string agentId)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.Request.Headers["X-Agent-Id"] = agentId;
|
||||||
|
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||||
|
HttpContextAccessor.HttpContext = httpContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCallerUser(string userId, string role)
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||||
|
new Claim(ClaimTypes.Role, role)
|
||||||
|
};
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
HttpContextAccessor.HttpContext = httpContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCallerServiceKey(string key)
|
||||||
|
{
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.Role, "Service") };
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.Request.Headers["X-Nexus-Api-Key"] = key;
|
||||||
|
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "ApiKey"));
|
||||||
|
HttpContextAccessor.HttpContext = httpContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateAgentConfigFile()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||||
|
File.WriteAllText(path,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "/workspace/default",
|
||||||
|
"model": {
|
||||||
|
"primary": "deepseek/deepseek-v4-flash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
|
||||||
|
{ "id": "product-owner", "name": "product-owner" },
|
||||||
|
{ "id": "programmer", "name": "programmer" },
|
||||||
|
{ "id": "programmer-fast", "name": "programmer-fast" },
|
||||||
|
{ "id": "reviewer", "name": "reviewer" },
|
||||||
|
{ "id": "architekt", "name": "architekt" },
|
||||||
|
{ "id": "executor", "name": "executor" },
|
||||||
|
{ "id": "researcher", "name": "researcher" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,26 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
|
|||||||
[HttpGet("/health/live")]
|
[HttpGet("/health/live")]
|
||||||
public IResult Live()
|
public IResult Live()
|
||||||
{
|
{
|
||||||
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow });
|
var agentCount = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetDirectoryName(
|
||||||
|
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? "/app",
|
||||||
|
"..");
|
||||||
|
var configPath = "/home/node/.openclaw/agents-sanitized.json";
|
||||||
|
if (System.IO.File.Exists(configPath))
|
||||||
|
{
|
||||||
|
var json = System.IO.File.ReadAllText(configPath);
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.TryGetProperty("agents", out var agentsEl)
|
||||||
|
&& agentsEl.TryGetProperty("list", out var listEl))
|
||||||
|
agentCount = listEl.GetArrayLength();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow, agentCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("/health")]
|
[HttpGet("/health")]
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
|||||||
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
|
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = configuration.GetValue<string>("AgentConfigPath")
|
var path = configuration.GetValue<string>("AgentConfigPath")
|
||||||
?? "/home/node/.openclaw/openclaw.json";
|
?? "/home/node/.openclaw/agents-sanitized.json";
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return BuildFallbackConfigs();
|
return BuildFallbackConfigs();
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load agent IDs from openclaw.json config
|
// Load agent IDs from sanitized agents config (no secrets)
|
||||||
var agentIds = LoadAgentIdsFromConfig();
|
var agentIds = LoadAgentIdsFromConfig();
|
||||||
|
|
||||||
var agents = new List<DashboardAgentInfo>();
|
var agents = new List<DashboardAgentInfo>();
|
||||||
@@ -227,7 +227,8 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads agent IDs from the OpenClaw config file (openclaw.json).
|
/// Loads agent IDs from the sanitized agents config (agents-sanitized.json).
|
||||||
|
/// No secrets — only agent list and defaults are exposed.
|
||||||
/// Falls back to the known list if the config file is unavailable.
|
/// Falls back to the known list if the config file is unavailable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private List<string> LoadAgentIdsFromConfig()
|
private List<string> LoadAgentIdsFromConfig()
|
||||||
@@ -235,7 +236,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
||||||
?? "/home/node/.openclaw/openclaw.json";
|
?? "/home/node/.openclaw/agents-sanitized.json";
|
||||||
|
|
||||||
if (!System.IO.File.Exists(configPath))
|
if (!System.IO.File.Exists(configPath))
|
||||||
return GetDefaultAgentIds();
|
return GetDefaultAgentIds();
|
||||||
@@ -1077,7 +1078,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the list of available models by reading from the OpenClaw config,
|
/// Returns the list of available models by reading from the sanitized agents config,
|
||||||
/// with fallback to hardcoded list.
|
/// with fallback to hardcoded list.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<ModelOption> GetAvailableModels()
|
public List<ModelOption> GetAvailableModels()
|
||||||
@@ -1085,7 +1086,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
||||||
?? "/home/node/.openclaw/openclaw.json";
|
?? "/home/node/.openclaw/agents-sanitized.json";
|
||||||
|
|
||||||
if (!System.IO.File.Exists(configPath))
|
if (!System.IO.File.Exists(configPath))
|
||||||
return GetDefaultModels();
|
return GetDefaultModels();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"AccessTokenExpirationMinutes": 15,
|
"AccessTokenExpirationMinutes": 15,
|
||||||
"RefreshTokenExpirationDays": 7
|
"RefreshTokenExpirationDays": 7
|
||||||
},
|
},
|
||||||
|
"AgentConfigPath": "/home/node/.openclaw/agents-sanitized.json",
|
||||||
"TaskRecovery": {
|
"TaskRecovery": {
|
||||||
"StaleHours": 2,
|
"StaleHours": 2,
|
||||||
"IntervalMinutes": 30
|
"IntervalMinutes": 30
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
volumes:
|
volumes:
|
||||||
- /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
|
- /home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json:/home/node/.openclaw/agents-sanitized.json:ro
|
||||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
||||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
||||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Agent Identity Architecture (P4)
|
||||||
|
|
||||||
|
> Status: ✅ Implemented (2026-07-13)
|
||||||
|
> Task: `4291d694-dd40-410d-b0d5-4742d334547a`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Nexus needed agent identity data (id, name, role, sub-agents, model) for the board/bridge
|
||||||
|
operations, but reading directly from `/home/node/.openclaw/openclaw.json` would expose
|
||||||
|
secrets (gateway password, API keys, auth profiles, channel tokens).
|
||||||
|
|
||||||
|
## Solution: Sanitized Agent Config File
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
openclaw.json (full config, SECRETS)
|
||||||
|
│
|
||||||
|
├── Deploy-time: jq extract → agents-sanitized.json (NO secrets)
|
||||||
|
│ └── deploy-nexus.sh: extracts only {"agents": ...} from openclaw.json
|
||||||
|
│
|
||||||
|
├── Manual sync: scripts/sync-agents-sanitized.mjs
|
||||||
|
│ └── node scripts/sync-agents-sanitized.mjs --once
|
||||||
|
│
|
||||||
|
└── Nexus API reads: agents-sanitized.json (read-only mount in compose)
|
||||||
|
├── AgentService.LoadAgentConfigsAsync()
|
||||||
|
├── OpenClawGatewayClient.LoadAgentIdsFromConfig()
|
||||||
|
└── AgentService.GetAllowedAgentIdsAsync()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
1. **Single sanitized source**: `agents-sanitized.json` contains ONLY the `agents` key
|
||||||
|
(list + defaults) — no `gateway`, `auth`, `channels`, `tools`, `plugins`, etc.
|
||||||
|
|
||||||
|
2. **Read-only mount**: Compose mounts as `:ro` — no write access from the API container
|
||||||
|
|
||||||
|
3. **No ACL dependency**: No uid-1654 ACL needed; the sanitized file is root-owned and
|
||||||
|
world-readable
|
||||||
|
|
||||||
|
4. **Graceful fallback**: If the sanitized file is missing, both `AgentService` and
|
||||||
|
`OpenClawGatewayClient` fall back to hardcoded agent IDs from `AgentIdentityCatalog`
|
||||||
|
|
||||||
|
5. **Auto-sync on deploy**: The deploy pipeline (`deploy-nexus.sh`) regenerates the
|
||||||
|
sanitized file from `openclaw.json` using `jq` in an alpine container
|
||||||
|
|
||||||
|
6. **Manual sync available**: `scripts/sync-agents-sanitized.mjs` provides on-demand
|
||||||
|
and watch-mode sync
|
||||||
|
|
||||||
|
### File Layout
|
||||||
|
|
||||||
|
| File | Location | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `openclaw.json` | `/home/node/.openclaw/openclaw.json` | Full config with secrets (gateway only) |
|
||||||
|
| `agents-sanitized.json` | `/home/node/.openclaw/agents-sanitized.json` | Agents-only, no secrets |
|
||||||
|
| Compose mount | `compose.yaml` → API container | `agents-sanitized.json:ro` |
|
||||||
|
| Deploy sanitizer | `.gitea/scripts/deploy-nexus.sh` | jq extraction on deploy |
|
||||||
|
| Sync script | `scripts/sync-agents-sanitized.mjs` | Node.js manual/watch sync |
|
||||||
|
| Config path | `backend/appsettings.json` | `AgentConfigPath` key |
|
||||||
|
|
||||||
|
### Security Guarantees
|
||||||
|
|
||||||
|
- ✅ No `password`, `token`, `secret`, or `api_key` values in `agents-sanitized.json`
|
||||||
|
- ✅ API endpoints (`/api/v1/agents`, `/api/v1/agents/{id}`) return ZERO secrets
|
||||||
|
- ✅ Gateway bridge controller (`/api/bridge/*`) uses only agent IDs from sanitized config
|
||||||
|
- ✅ No direct `openclaw.json` reads in any C# code path
|
||||||
|
- ✅ Agent identity catalog (`AgentIdentityCatalog`) is a hardcoded fallback, not a primary source
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check sanitized file has no secrets
|
||||||
|
curl -s http://nexus-api-1:8080/api/v1/agents \
|
||||||
|
-H "X-Api-Key: <key>" | grep -i "password\|secret\|token\|apikey"
|
||||||
|
# Expected: no output
|
||||||
|
|
||||||
|
# Verify only "agents" key exists in sanitized file
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
with open('agents-sanitized.json') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
print(list(data.keys())) # Should print ['agents']
|
||||||
|
"
|
||||||
|
```
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* sync-agents-sanitized.mjs
|
||||||
|
*
|
||||||
|
* Keeps agents-sanitized.json in sync with openclaw.json.
|
||||||
|
* Strips all secrets (gateway, channels, auth, tools, plugins, etc.)
|
||||||
|
* and only writes the "agents" key.
|
||||||
|
*
|
||||||
|
* Modes:
|
||||||
|
* --once Run once and exit
|
||||||
|
* --watch Watch openclaw.json and re-generate on changes (default)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node sync-agents-sanitized.mjs --once
|
||||||
|
* node sync-agents-sanitized.mjs --watch
|
||||||
|
*
|
||||||
|
* Paths (defaults, override with OPENCLAW_CONFIG and SANITIZED_OUTPUT env vars):
|
||||||
|
* Source: /home/node/.openclaw/openclaw.json
|
||||||
|
* Output: /home/node/.openclaw/agents-sanitized.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { watch } from 'node:fs';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const SRC = process.env.OPENCLAW_CONFIG || '/home/node/.openclaw/openclaw.json';
|
||||||
|
const OUT = process.env.SANITIZED_OUTPUT || '/home/node/.openclaw/agents-sanitized.json';
|
||||||
|
|
||||||
|
let running = true;
|
||||||
|
let debounceTimer = null;
|
||||||
|
const DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
process.stderr.write(`[agents-sanitized ${ts}] ${msg}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(SRC, 'utf-8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
const agents = data?.agents;
|
||||||
|
if (!agents) {
|
||||||
|
log(`ERROR: "agents" key not found in ${SRC}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = { agents };
|
||||||
|
const json = JSON.stringify(sanitized, null, 2) + '\n';
|
||||||
|
await writeFile(OUT, json, 'utf-8');
|
||||||
|
|
||||||
|
const agentCount = agents?.list?.length ?? 0;
|
||||||
|
log(`Generated ${OUT} with ${agentCount} agents (${json.length} bytes)`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
log(`ERROR: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const mode = process.argv.includes('--once') ? 'once' : 'watch';
|
||||||
|
log(`Starting in ${mode} mode`);
|
||||||
|
log(` Source: ${SRC}`);
|
||||||
|
log(` Output: ${OUT}`);
|
||||||
|
|
||||||
|
// Initial generation
|
||||||
|
const ok = await generate();
|
||||||
|
if (mode === 'once') {
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch mode
|
||||||
|
log('Watching for changes...');
|
||||||
|
watch(SRC, (eventType) => {
|
||||||
|
if (eventType !== 'change') return;
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
log(`Detected change in ${SRC}`);
|
||||||
|
await generate();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep process alive
|
||||||
|
process.on('SIGINT', () => { running = false; process.exit(0); });
|
||||||
|
process.on('SIGTERM', () => { running = false; process.exit(0); });
|
||||||
|
|
||||||
|
// Periodic check every 5 minutes as fallback
|
||||||
|
setInterval(async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
log('Periodic re-sync check');
|
||||||
|
await generate();
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
log(`FATAL: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user