Compare commits

..

8 Commits

Author SHA1 Message Date
developer 42e4ea4497 P1b: Document read-only MCP tools as TaskBridgeService facade
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Failing after 24s
Implementation verified (originally in aaec3eb):
- nexus_get_board: pure facade → bridge.GetBoardAsync()
- nexus_agent_overview: pure facade → bridge.GetAgentOverviewAsync()
- nexus_get_task: pure facade → bridge.GetTaskAsync() + ToResponse()
- nexus_get_children: pure facade → bridge.GetChildTasksAsync()
- nexus_get_activity: pure facade → bridge.GetTaskActivityAsync() + DTO map

All 5 tools register via [McpServerTool] attribute on McpServerToolType class.
No business logic duplication — every tool delegates directly to ITaskBridgeService.
Build: 0w 0e. MCP tests: 44/44 passed.
2026-07-13 20:05:19 +02:00
developer 1732fa97ad P1a: Fix MapMcp to use explicit /mcp route pattern
The parameterless MapMcp() overload did not register the endpoint
correctly at the /mcp path. Changed to MapMcp("/mcp") which
successfully registers the MCP Streamable HTTP endpoint.

Verified:
- POST /mcp with initialize request returns valid MCP response
- tools/list returns all 10 NexusMcpTools
- Health check passes
- Build: 0w 0e
2026-07-13 19:10:42 +02:00
developer c4270a4975 P1a: Ensure ApiKeyMiddleware skips /mcp endpoint
The MCP endpoint handles its own authentication via X-Agent-Id and
X-Nexus-Api-Key headers through NexusMcpTools. The ApiKeyMiddleware
now skips the /mcp path to avoid interfering with MCP's own auth flow.
2026-07-13 16:11:09 +02:00
developer 6ba788bb6e P2.1: Add GatewayConnector BackgroundService with WebSocket client
- GatewayConnector: BackgroundService maintaining persistent WebSocket connection
  to OpenClaw Gateway with Bearer token auth from backend env only
- IGatewayConnector: Interface exposing connection state, version info, health
- Exponential backoff reconnect: 1s initial, max 5min, with jitter
- Version pinning: RequiredVersion from config; fail-fast or warn on mismatch
- Pre-flight HTTP version check before WebSocket connect
- GatewayHealthController: GET /api/health/gateway with connection status
- GatewayConnectorOptions: Configurable via GatewayConnector config section
- Token never logged; read from Integrations:OpenClaw:Password/Token only
- 16 tests: options defaults, config binding, health endpoint responses,
  connection states, DI registration, routing attributes
2026-07-13 14:13:39 +02:00
developer a55951f315 P4: Agent-Identitäten ohne Secrets — sanitized config feed
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Failing after 19s
- Replace Python-based sanitizer in deploy script with lightweight jq/alpine
- Add sync-agents-sanitized.mjs for on-demand and watch-mode sync
- Add AgentConfigPath to appsettings.json (explicit default)
- Extend /health/live endpoint to report agent count from sanitized config
- Document architecture in docs/agent-identity-architecture.md
- No openclaw.json secrets ever reach Nexus API containers

Verification:
- curl /api/v1/agents → 9 agents, zero secrets in response
- agents-sanitized.json contains only 'agents' key, no gateway/auth
- All C# code paths read from agents-sanitized.json (AgentConfigPath)
- Bridge controller resolves agent IDs via AgentService.GetAllowedAgentIdsAsync()
2026-07-13 09:12:19 +02:00
developer 77b9587fa6 test: add comprehensive MCP tool and server configuration tests
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Failing after 10s
- McpToolsTests: 33 tests covering tool registration, enum validation,
  state transitions, auth resolution (JWT, API key, X-Agent-Id),
  create/read/update/activity/handoff workflows via McpToolsFixture
- McpServerConfigurationTests: 5 tests verifying MCP server DI
  registration, tool type attribute, MapMcp endpoint wiring,
  service extensions, and NuGet package reference
2026-07-13 08:10:36 +02:00
developer 17dc84082c feat: switch agent identity from openclaw.json to agents-sanitized.json
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Failing after 19s
Completes P4 migration: Nexus API no longer reads openclaw.json.

Changes:
- AgentService: default path -> agents-sanitized.json
- OpenClawGatewayClient: LoadAgentIdsFromConfig + GetAvailableModels
  both now read from agents-sanitized.json
- compose.yaml: mount agents-sanitized.json instead of openclaw.json

The deploy script (.gitea/scripts/deploy-nexus.sh) already generates
agents-sanitized.json during deployment, extracting only the
section (list + defaults) without gateway/auth/password secrets.

DoD:
- No read access to openclaw.json from Nexus API container
- No uid-1654 ACL dependency for agent identity resolution
- No secrets in agent responses or config files
2026-07-12 22:07:11 +02:00
devops 8ad8c956eb fix(deploy): sanitized agent config — use Docker to read openclaw.json
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 27s
The Gitea runner doesn't have direct filesystem access to
/home/projekte_bao/openclaw/, so the previous python3 inline extraction
would fail silently and no agents-sanitized.json would be generated.

Now the deploy script uses a Docker container with bind mounts to read
openclaw.json (readonly) and write agents-sanitized.json to the host.

This completes the P4 migration: Nexus no longer needs read access to
openclaw.json for any code path or deployment step.
2026-07-12 15:08:49 +02:00
18 changed files with 1851 additions and 9 deletions
+30
View File
@@ -105,6 +105,36 @@ git archive --format=tar HEAD | docker run --rm -i \
chown -R "$dest_owner" /dest
'
# ── Sanitized agents config for Nexus (no secrets) ──
echo "Generating sanitized agents config for Nexus (no secrets from openclaw.json)"
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
OPENCLAW_CONFIG_DIR="/home/projekte_bao/openclaw/data/openclaw"
# 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 \
-v "$DEPLOY_PATH:/workspace/nexus" \
+311
View File
@@ -0,0 +1,311 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Nexus.Api.Controllers;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public class GatewayConnectorTests
{
// ── Options / Configuration tests ──
[Fact]
public void Options_DefaultWebSocketPath_IsWs()
{
var options = new GatewayConnectorOptions();
Assert.Equal("/ws", options.WebSocketPath);
}
[Fact]
public void Options_DefaultReconnectInitialDelay_Is1000ms()
{
var options = new GatewayConnectorOptions();
Assert.Equal(1000, options.ReconnectInitialDelayMs);
}
[Fact]
public void Options_DefaultReconnectMaxDelay_Is5Minutes()
{
var options = new GatewayConnectorOptions();
Assert.Equal(300_000, options.ReconnectMaxDelayMs);
}
[Fact]
public void Options_FailFastOnVersionMismatch_IsTrueByDefault()
{
var options = new GatewayConnectorOptions();
Assert.True(options.FailFastOnVersionMismatch);
}
[Fact]
public void Options_FailFastOnMissingVersion_IsFalseByDefault()
{
var options = new GatewayConnectorOptions();
Assert.False(options.FailFastOnMissingVersion);
}
// ── Configuration binding tests ──
[Fact]
public void Options_BindFromConfiguration()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["GatewayConnector:WebSocketPath"] = "/events",
["GatewayConnector:ReconnectInitialDelayMs"] = "2000",
["GatewayConnector:ReconnectMaxDelayMs"] = "60000",
["GatewayConnector:FailFastOnVersionMismatch"] = "false",
["GatewayConnector:FailFastOnMissingVersion"] = "true"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddOptions<GatewayConnectorOptions>()
.BindConfiguration(GatewayConnectorOptions.SectionName);
var sp = services.BuildServiceProvider();
var options = sp.GetRequiredService<IOptions<GatewayConnectorOptions>>().Value;
Assert.Equal("/events", options.WebSocketPath);
Assert.Equal(2000, options.ReconnectInitialDelayMs);
Assert.Equal(60000, options.ReconnectMaxDelayMs);
Assert.False(options.FailFastOnVersionMismatch);
Assert.True(options.FailFastOnMissingVersion);
}
// ── Health endpoint tests ──
[Fact]
public async Task HealthEndpoint_ReturnsCorrectStructure()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Connected, "2.103.0", "2.103.0",
DateTimeOffset.UtcNow, 0, "Gateway verbunden"));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("connected", root.GetProperty("status").GetString());
Assert.True(root.GetProperty("connected").GetBoolean());
Assert.Equal("2.103.0", root.GetProperty("gatewayVersion").GetString());
Assert.Equal("2.103.0", root.GetProperty("requiredVersion").GetString());
Assert.True(root.GetProperty("versionPinned").GetBoolean());
Assert.Equal(0, root.GetProperty("reconnectAttempts").GetInt32());
}
[Fact]
public async Task HealthEndpoint_Disconnected_ShowsDisconnected()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Disconnected, null, "2.103.0",
null, 5, "Connection refused"));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("disconnected", root.GetProperty("status").GetString());
Assert.False(root.GetProperty("connected").GetBoolean());
Assert.Equal("unknown", root.GetProperty("gatewayVersion").GetString());
Assert.Equal(5, root.GetProperty("reconnectAttempts").GetInt32());
Assert.Equal("Connection refused", root.GetProperty("message").GetString());
}
[Fact]
public async Task HealthEndpoint_Reconnecting_ShowsReconnecting()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Reconnecting, null, null,
null, 3, "Reconnecting..."));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("reconnecting", root.GetProperty("status").GetString());
Assert.False(root.GetProperty("connected").GetBoolean());
Assert.False(root.GetProperty("versionPinned").GetBoolean());
}
[Fact]
public async Task HealthEndpoint_Failed_ShowsFailed()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Failed, "2.100.0", "2.103.0",
null, 10, "Version mismatch — fail-fast"));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.Equal("failed", root.GetProperty("status").GetString());
Assert.False(root.GetProperty("connected").GetBoolean());
Assert.Equal("2.100.0", root.GetProperty("gatewayVersion").GetString());
Assert.Equal("2.103.0", root.GetProperty("requiredVersion").GetString());
Assert.Equal(10, root.GetProperty("reconnectAttempts").GetInt32());
Assert.True(root.TryGetProperty("timestamp", out _));
}
[Fact]
public async Task HealthEndpoint_Unpinned_ShowsVersionPinnedFalse()
{
var controller = new GatewayHealthController(new FakeGatewayConnector(
GatewayConnectionState.Connected, "2.103.0", null,
DateTimeOffset.UtcNow, 0, null));
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.False(root.GetProperty("versionPinned").GetBoolean());
Assert.Equal(JsonValueKind.Null, root.GetProperty("requiredVersion").ValueKind);
}
// ── Connection state transition tests ──
[Fact]
public void Connector_InitialState_IsInitializing()
{
var connector = new FakeGatewayConnector(
GatewayConnectionState.Initializing, null, null, null, 0, null);
Assert.Equal(GatewayConnectionState.Initializing, connector.ConnectionState);
Assert.Null(connector.GatewayVersion);
Assert.Null(connector.LastConnectedAt);
Assert.Equal(0, connector.ReconnectAttempts);
}
[Fact]
public void Connector_AfterConnection_ReportsConnected()
{
var now = DateTimeOffset.UtcNow;
var connector = new FakeGatewayConnector(
GatewayConnectionState.Connected, "2.103.0", "2.103.0",
now, 0, null);
Assert.Equal(GatewayConnectionState.Connected, connector.ConnectionState);
Assert.Equal("2.103.0", connector.GatewayVersion);
Assert.Equal("2.103.0", connector.RequiredVersion);
Assert.NotNull(connector.LastConnectedAt);
Assert.Equal(0, connector.ReconnectAttempts);
}
// ── DI registration tests ──
[Fact]
public void Connector_CanBeRegisteredInDi()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Integrations:OpenClaw:BaseUrl"] = "http://localhost:18789",
["Integrations:OpenClaw:RequiredVersion"] = "2.103.0"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddLogging();
services.AddHttpClient("gateway");
services.AddOptions<GatewayConnectorOptions>()
.BindConfiguration(GatewayConnectorOptions.SectionName);
services.AddSingleton<IGatewayConnector, GatewayConnector>();
var sp = services.BuildServiceProvider();
var connector = sp.GetRequiredService<IGatewayConnector>();
Assert.NotNull(connector);
Assert.Equal(GatewayConnectionState.Initializing, connector.ConnectionState);
}
// ── HTTP endpoint routing test ──
[Fact]
public void HealthEndpoint_HasAllowAnonymous()
{
var type = typeof(GatewayHealthController);
var attr = type.GetCustomAttributes(true)
.OfType<Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute>()
.FirstOrDefault();
Assert.NotNull(attr);
}
[Fact]
public void HealthEndpoint_HasCorrectRoute()
{
var method = typeof(GatewayHealthController).GetMethod("GetGatewayHealth");
Assert.NotNull(method);
var routeAttr = method!.GetCustomAttributes(true)
.OfType<Microsoft.AspNetCore.Mvc.HttpGetAttribute>()
.FirstOrDefault();
Assert.NotNull(routeAttr);
Assert.Contains("/api/health/gateway", routeAttr!.Template!);
}
// ── Helpers ──
private static async Task<string> ExecuteAndReadJsonAsync(IResult result)
{
var services = new ServiceCollection();
services.AddLogging();
services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase);
var sp = services.BuildServiceProvider();
var httpContext = new DefaultHttpContext { RequestServices = sp };
// HttpResults.Ok<T> needs a response body to write to
httpContext.Response.Body = new MemoryStream();
await result.ExecuteAsync(httpContext);
httpContext.Response.Body.Position = 0;
using var reader = new StreamReader(httpContext.Response.Body);
return reader.ReadToEnd();
}
}
/// <summary>
/// Test double implementing all members of IGatewayConnector.
/// </summary>
public sealed class FakeGatewayConnector : IGatewayConnector
{
private readonly GatewayConnectionState _state;
private readonly string? _gatewayVersion;
private readonly string? _requiredVersion;
private readonly DateTimeOffset? _lastConnectedAt;
private readonly int _reconnectAttempts;
private readonly string? _statusMessage;
public FakeGatewayConnector(
GatewayConnectionState state,
string? gatewayVersion,
string? requiredVersion,
DateTimeOffset? lastConnectedAt,
int reconnectAttempts,
string? statusMessage)
{
_state = state;
_gatewayVersion = gatewayVersion;
_requiredVersion = requiredVersion;
_lastConnectedAt = lastConnectedAt;
_reconnectAttempts = reconnectAttempts;
_statusMessage = statusMessage;
}
public GatewayConnectionState ConnectionState => _state;
public string? GatewayVersion => _gatewayVersion;
public string? RequiredVersion => _requiredVersion;
public DateTimeOffset? LastConnectedAt => _lastConnectedAt;
public int ReconnectAttempts => _reconnectAttempts;
public string? StatusMessage => _statusMessage;
}
@@ -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());
}
}
+582
View File
@@ -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;
}
}
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// Health-check endpoint for the Gateway WebSocket connection.
/// </summary>
[ApiController]
[AllowAnonymous]
public class GatewayHealthController(IGatewayConnector connector) : ControllerBase
{
/// <summary>
/// Returns the current Gateway WebSocket connection health.
/// </summary>
[HttpGet("/api/health/gateway")]
public IResult GetGatewayHealth()
{
return Results.Ok(new
{
status = connector.ConnectionState.ToString().ToLowerInvariant(),
connected = connector.ConnectionState == GatewayConnectionState.Connected,
gatewayVersion = connector.GatewayVersion ?? "unknown",
requiredVersion = connector.RequiredVersion,
versionPinned = connector.RequiredVersion is not null,
lastConnectedAt = connector.LastConnectedAt?.ToString("o"),
reconnectAttempts = connector.ReconnectAttempts,
message = connector.StatusMessage,
timestamp = DateTimeOffset.UtcNow.ToString("o")
});
}
}
+20 -1
View File
@@ -12,7 +12,26 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
[HttpGet("/health/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")]
@@ -229,6 +229,12 @@ public static class ServiceCollectionExtensions
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
// ── Gateway WebSocket Connector ──
services.AddOptions<GatewayConnectorOptions>()
.BindConfiguration(GatewayConnectorOptions.SectionName);
services.AddSingleton<IGatewayConnector, GatewayConnector>();
services.AddHostedService(sp => (GatewayConnector)sp.GetRequiredService<IGatewayConnector>());
// ── Backend Bridge (Agent-Command-Service) ──
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
+12
View File
@@ -6,11 +6,23 @@ namespace Nexus.Api.Middleware;
/// Middleware that authenticates requests via the X-Nexus-Api-Key header.
/// On match, sets a ClaimsPrincipal with role "Service".
/// On mismatch or absent header, passes through to next middleware (JWT auth).
///
/// The MCP endpoint (/mcp) is intentionally skipped — the MCP SDK handles its own
/// authentication via X-Agent-Id + X-Nexus-Api-Key headers through NexusMcpTools.
/// </summary>
public sealed class ApiKeyMiddleware(RequestDelegate next)
{
private static readonly PathString McpPath = new("/mcp");
public async Task InvokeAsync(HttpContext context)
{
// MCP endpoint handles its own auth — skip ApiKey interference
if (context.Request.Path.StartsWithSegments(McpPath))
{
await next(context);
return;
}
var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
var apiKey = configuration["NexusApiKey"];
+1 -1
View File
@@ -22,6 +22,6 @@ await app.EnsureDatabaseAsync();
// --- Middleware Pipeline ---
app.UseNexusPipeline(app.Environment);
app.MapMcp();
app.MapMcp("/mcp");
app.MapControllers();
app.Run();
+1 -1
View File
@@ -235,7 +235,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{
var path = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json";
?? "/home/node/.openclaw/agents-sanitized.json";
if (!File.Exists(path))
return BuildFallbackConfigs();
+511
View File
@@ -0,0 +1,511 @@
using System.Net.Http.Headers;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace Nexus.Api.Services;
/// <summary>
/// BackgroundService that maintains a persistent WebSocket connection to the
/// OpenClaw Gateway for real-time event streaming and health monitoring.
///
/// Uses exponential backoff for reconnects, never logs the gateway token,
/// and reports connection state via <see cref="IGatewayConnector"/>.
/// </summary>
public sealed class GatewayConnector : BackgroundService, IGatewayConnector
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<GatewayConnector> _logger;
private readonly GatewayConnectorOptions _options;
// ── Connection state (lock-protected) ──
private readonly object _lock = new();
private GatewayConnectionState _state = GatewayConnectionState.Initializing;
private string? _gatewayVersion;
private string? _requiredVersion;
private DateTimeOffset? _lastConnectedAt;
private int _reconnectAttempts;
private string? _statusMessage;
public GatewayConnector(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IOptions<GatewayConnectorOptions> options,
ILogger<GatewayConnector> logger)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_logger = logger;
_options = options.Value;
}
// ── IGatewayConnector ──
public GatewayConnectionState ConnectionState
{
get { lock (_lock) return _state; }
}
public string? GatewayVersion
{
get { lock (_lock) return _gatewayVersion; }
}
public string? RequiredVersion
{
get { lock (_lock) return _requiredVersion; }
}
public DateTimeOffset? LastConnectedAt
{
get { lock (_lock) return _lastConnectedAt; }
}
public int ReconnectAttempts
{
get { lock (_lock) return _reconnectAttempts; }
}
public string? StatusMessage
{
get { lock (_lock) return _statusMessage; }
}
// ── BackgroundService ──
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Load version pin from configuration once at startup
var pinnedVersion = _configuration["Integrations:OpenClaw:RequiredVersion"];
lock (_lock)
{
_requiredVersion = string.IsNullOrWhiteSpace(pinnedVersion) ? null : pinnedVersion.Trim();
}
if (_requiredVersion is null)
{
_logger.LogWarning(
"Gateway version is UNPINNED (Integrations:OpenClaw:RequiredVersion is empty). "
+ "Drift detection is disabled; set a required version to enable fail-fast on mismatch.");
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ConnectAndReceiveAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "GatewayConnector connection loop exception (will retry)");
}
// Exponential backoff before next reconnect attempt
if (!stoppingToken.IsCancellationRequested)
{
var delay = ComputeBackoff();
_logger.LogInformation(
"GatewayConnector reconnecting in {DelayMs}ms (attempt {Attempt})",
(int)delay.TotalMilliseconds, GetReconnectAttempts() + 1);
SetState(GatewayConnectionState.Reconnecting);
try { await Task.Delay(delay, stoppingToken); }
catch (OperationCanceledException) { break; }
}
}
_logger.LogInformation("GatewayConnector background service stopped");
}
private async Task ConnectAndReceiveAsync(CancellationToken stoppingToken)
{
using var ws = new ClientWebSocket();
ConfigureWebSocketWithAuth(ws);
var baseUrl = _configuration["Integrations:OpenClaw:BaseUrl"] ?? "http://127.0.0.1:18789";
var wsBase = ConvertToWebSocketUrl(baseUrl);
// ── Step 1: Pre-flight version check via HTTP ──
string? detectedVersion = null;
try
{
detectedVersion = await FetchGatewayVersionAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Pre-flight version check failed (non-fatal)");
}
// Version check against the pin
var (versionOk, versionWarning) = EvaluateVersion(detectedVersion);
if (!versionOk)
{
if (_options.FailFastOnVersionMismatch)
{
_logger.LogError(
"Gateway version mismatch — fail-fast enabled. Detected: {Detected}, Required: {Required}",
detectedVersion ?? "none", _requiredVersion);
IncrementReconnectAttempts();
SetState(GatewayConnectionState.Failed,
$"Version mismatch — fail-fast: detected '{detectedVersion}', required '{_requiredVersion}'.");
return;
}
_logger.LogWarning("Gateway version mismatch (non-fatal): {Warning}", versionWarning);
}
// ── Step 2: Establish WebSocket connection ──
var wsUri = new Uri(new Uri(wsBase), _options.WebSocketPath);
_logger.LogInformation("GatewayConnector connecting to {Uri}", wsUri);
try
{
await ws.ConnectAsync(wsUri, stoppingToken);
}
catch (WebSocketException ex)
{
_logger.LogWarning(ex, "WebSocket connection failed to {Uri} — gateway may not expose a WS endpoint", wsUri);
IncrementReconnectAttempts();
SetState(GatewayConnectionState.Disconnected,
$"Connection refused — WebSocket endpoint may not be available at {wsUri}. Error: {ex.Message}");
return;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "WebSocket HTTP upgrade failed to {Uri}", wsUri);
IncrementReconnectAttempts();
SetState(GatewayConnectionState.Disconnected,
$"HTTP upgrade failed: {ex.Message}");
return;
}
// ── Step 3: Submit authentication token (if configured) ──
var token = ResolveToken();
if (token is not null)
{
try
{
var authFrame = Encoding.UTF8.GetBytes(
JsonSerializer.Serialize(new { type = "auth", token }));
await ws.SendAsync(
new ArraySegment<byte>(authFrame),
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken: stoppingToken);
_logger.LogDebug("WebSocket auth token sent");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send WebSocket auth frame");
}
}
// ── After successful connection ──
lock (_lock)
{
_state = GatewayConnectionState.Connected;
_gatewayVersion = detectedVersion;
_lastConnectedAt = DateTimeOffset.UtcNow;
_reconnectAttempts = 0;
_statusMessage = versionWarning;
}
_logger.LogInformation(
"GatewayConnector connected. Version: {Version}, Required: {Required}",
detectedVersion ?? "unknown", _requiredVersion ?? "unpinned");
// ── Step 4: Receive loop (heartbeat / event processing) ──
await ReceiveLoopAsync(ws, stoppingToken);
}
private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken stoppingToken)
{
var buffer = new byte[4096];
try
{
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), stoppingToken);
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.LogInformation(
"GatewayConnector received close frame: {Description}",
result.CloseStatusDescription ?? "no description");
break;
}
// Process text frames (events, heartbeats, status updates)
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
ProcessGatewayMessage(message);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Expected on shutdown
}
catch (WebSocketException ex)
{
_logger.LogWarning(ex, "WebSocket connection lost");
}
finally
{
if (ws.State == WebSocketState.Open || ws.State == WebSocketState.CloseReceived)
{
try
{
await ws.CloseAsync(
WebSocketCloseStatus.NormalClosure, "Connector shutdown", CancellationToken.None);
}
catch { /* Best effort */ }
}
SetState(GatewayConnectionState.Disconnected, "Connection closed");
}
}
private void ProcessGatewayMessage(string message)
{
try
{
using var doc = JsonDocument.Parse(message);
var root = doc.RootElement;
var type = root.TryGetProperty("type", out var typeEl) ? typeEl.GetString() : null;
switch (type)
{
case "heartbeat":
case "pong":
_logger.LogDebug("Gateway heartbeat received");
break;
case "event":
var eventName = root.TryGetProperty("name", out var nameEl)
? nameEl.GetString() : "unknown";
_logger.LogDebug("Gateway event: {EventName}", eventName);
// Future: fan-out to other services via a channel/event bus
break;
case "error":
var errorMsg = root.TryGetProperty("message", out var msgEl)
? msgEl.GetString() : "unknown error";
_logger.LogWarning("Gateway error frame received: {Error}", errorMsg);
break;
default:
_logger.LogDebug("Gateway message (type={Type}): {Preview}",
type ?? "none", Truncate(message, 120));
break;
}
}
catch (JsonException)
{
_logger.LogDebug("Non-JSON gateway message: {Preview}", Truncate(message, 80));
}
}
// ── Helpers ──
private (bool Ok, string? Warning) EvaluateVersion(string? detectedVersion)
{
if (_requiredVersion is null)
return (true, null); // Unpinned — always ok
if (detectedVersion is null)
return (_options.FailFastOnMissingVersion ? false : true,
"Gateway version not detected while version pin is set.");
if (!string.Equals(detectedVersion, _requiredVersion, StringComparison.OrdinalIgnoreCase))
return (false, $"Version drift: detected '{detectedVersion}', required '{_requiredVersion}'.");
return (true, null);
}
private async Task<string?> FetchGatewayVersionAsync(CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("gateway");
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
ApplyAuth(request);
using var response = await client.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
return null;
// Check X-OpenClaw-Version header first
if (response.Headers.TryGetValues("X-OpenClaw-Version", out var headerValues))
{
var version = headerValues.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(version))
return version.Trim();
}
// Try JSON body
var body = await response.Content.ReadAsStringAsync(ct);
if (!string.IsNullOrWhiteSpace(body))
{
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
var v = TryGetString(root, "version")
?? TryGetString(root, "gatewayVersion")
?? TryGetString(root, "openclawVersion");
if (v is not null) return v;
}
catch { /* Not JSON */ }
}
return null;
}
catch
{
return null;
}
}
private TimeSpan ComputeBackoff()
{
var attempts = GetReconnectAttempts();
var backoffMs = (int)Math.Min(
_options.ReconnectInitialDelayMs * Math.Pow(2, attempts),
_options.ReconnectMaxDelayMs);
// Add jitter (±25%)
var jitter = (int)(backoffMs * 0.25 * (Random.Shared.NextDouble() * 2 - 1));
return TimeSpan.FromMilliseconds(Math.Max(100, backoffMs + jitter));
}
private void IncrementReconnectAttempts()
{
lock (_lock) { _reconnectAttempts++; }
}
private int GetReconnectAttempts()
{
lock (_lock) { return _reconnectAttempts; }
}
private void SetState(GatewayConnectionState state, string? message = null)
{
lock (_lock)
{
_state = state;
if (message is not null) _statusMessage = message;
}
}
private string? ResolveToken()
{
// Token NEVER logged — only read from config here
var token = _configuration["Integrations:OpenClaw:Password"]
?? _configuration["Integrations:OpenClaw:Token"];
return string.IsNullOrWhiteSpace(token) ? null : token;
}
private void ApplyAuth(HttpRequestMessage request)
{
var token = ResolveToken();
if (token is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
private static void ConfigureWebSocket(ClientWebSocket ws)
{
// Add auth header to the initial HTTP upgrade request
ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
ws.Options.UseDefaultCredentials = false;
}
// Overload with auth header via cookie (WebSocket can't do Authorization header natively in all runtimes)
private void ConfigureWebSocketWithAuth(ClientWebSocket ws)
{
var token = ResolveToken();
if (token is not null)
{
ws.Options.SetRequestHeader("Authorization", "Bearer " + token);
}
}
private static string ConvertToWebSocketUrl(string baseUrl)
{
var trimmed = baseUrl.TrimEnd('/');
if (trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return "wss://" + trimmed["https://".Length..];
if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
return "ws://" + trimmed["http://".Length..];
return "ws://" + trimmed;
}
private static string? TryGetString(JsonElement root, string property)
=> root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(property, out var value)
&& value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
private static string Truncate(string value, int maxLength)
=> value.Length <= maxLength ? value : value[..maxLength] + "\u2026";
}
/// <summary>
/// Configuration options for the GatewayConnector background service.
/// </summary>
public sealed class GatewayConnectorOptions
{
public const string SectionName = "GatewayConnector";
/// <summary>
/// WebSocket path relative to the gateway base URL.
/// Default: "/ws"
/// </summary>
public string WebSocketPath { get; set; } = "/ws";
/// <summary>
/// Initial reconnect delay in milliseconds.
/// Default: 1000 (1 second)
/// </summary>
public int ReconnectInitialDelayMs { get; set; } = 1000;
/// <summary>
/// Maximum reconnect delay in milliseconds.
/// Default: 300_000 (5 minutes)
/// </summary>
public int ReconnectMaxDelayMs { get; set; } = 300_000;
/// <summary>
/// Maximum number of consecutive reconnect attempts before entering Failed state.
/// Default: 0 (no limit — keep retrying forever)
/// </summary>
public int ReconnectMaxAttempts { get; set; }
/// <summary>
/// When true, the connector enters Failed state immediately if the gateway version
/// doesn't match the pinned required version (instead of connecting with a warning).
/// Default: true
/// </summary>
public bool FailFastOnVersionMismatch { get; set; } = true;
/// <summary>
/// When true, missing version (gateway doesn't report one) with a version pin set
/// also triggers fail-fast. Default: false (allows pinned-version gateways that
/// don't expose a version endpoint).
/// </summary>
public bool FailFastOnMissingVersion { get; set; }
}
+57
View File
@@ -0,0 +1,57 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
/// <summary>
/// Maintains a persistent WebSocket connection to the OpenClaw Gateway
/// for real-time event streaming and health monitoring.
/// </summary>
public interface IGatewayConnector
{
/// <summary>
/// Current WebSocket connection state.
/// </summary>
GatewayConnectionState ConnectionState { get; }
/// <summary>
/// Gateway version reported at last connection.
/// </summary>
string? GatewayVersion { get; }
/// <summary>
/// Required version from configuration, or null when unpinned.
/// </summary>
string? RequiredVersion { get; }
/// <summary>
/// Timestamp of the last successful connection attempt.
/// </summary>
DateTimeOffset? LastConnectedAt { get; }
/// <summary>
/// Number of consecutive failed connection attempts.
/// </summary>
int ReconnectAttempts { get; }
/// <summary>
/// Detailed status message (e.g. error or version info).
/// </summary>
string? StatusMessage { get; }
}
/// <summary>
/// Represents the current connection state of the GatewayConnector.
/// </summary>
public enum GatewayConnectionState
{
/// <summary>Not yet attempted or initializing.</summary>
Initializing,
/// <summary>Connected and healthy.</summary>
Connected,
/// <summary>Disconnected, waiting for reconnect.</summary>
Disconnected,
/// <summary>Recoverable error, retrying.</summary>
Reconnecting,
/// <summary>Failed after maximum retries.</summary>
Failed
}
+4
View File
@@ -15,6 +15,8 @@ public sealed class NexusMcpTools(
IConfiguration configuration,
ILogger<NexusMcpTools> logger)
{
// ── P1b: Read-only MCP Tools (TaskBridgeService facade) ──
[McpServerTool(Name = "nexus_get_board")]
[Description("Get the full Nexus task board grouped by canonical states.")]
public async Task<BoardResponse> GetBoard(CancellationToken ct = default)
@@ -59,6 +61,8 @@ public sealed class NexusMcpTools(
return activity.Select(entry => new ActivityEntryDto(entry.Id, entry.Type, entry.Message, entry.CreatedAt)).ToList();
}
// ── P1c: Mutating MCP Tools ──
[McpServerTool(Name = "nexus_create_task")]
[Description("Create a top-level Nexus task.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateTask(
+6 -5
View File
@@ -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 agents = new List<DashboardAgentInfo>();
@@ -227,7 +227,8 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
}
/// <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.
/// </summary>
private List<string> LoadAgentIdsFromConfig()
@@ -235,7 +236,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
try
{
var configPath = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json";
?? "/home/node/.openclaw/agents-sanitized.json";
if (!System.IO.File.Exists(configPath))
return GetDefaultAgentIds();
@@ -1077,7 +1078,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
}
/// <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.
/// </summary>
public List<ModelOption> GetAvailableModels()
@@ -1085,7 +1086,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
try
{
var configPath = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json";
?? "/home/node/.openclaw/agents-sanitized.json";
if (!System.IO.File.Exists(configPath))
return GetDefaultModels();
+8
View File
@@ -22,9 +22,17 @@
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
},
"AgentConfigPath": "/home/node/.openclaw/agents-sanitized.json",
"TaskRecovery": {
"StaleHours": 2,
"IntervalMinutes": 30
},
"GatewayConnector": {
"WebSocketPath": "/ws",
"ReconnectInitialDelayMs": 1000,
"ReconnectMaxDelayMs": 300000,
"FailFastOnVersionMismatch": true,
"FailFastOnMissingVersion": false
},
"AllowedHosts": "*"
}
+1 -1
View File
@@ -68,7 +68,7 @@ services:
retries: 3
start_period: 15s
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-programmer:/mnt/workspace-programmer
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
+84
View File
@@ -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']
"
```
+99
View File
@@ -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);
});